diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e0e1b3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,74 @@ +# Dependencies +node_modules/ +*/node_modules/ +.pnp +.pnp.js + +# Build outputs +dist/ +build/ +.next/ +out/ + +# Environment files +.env +.env.local +.env.*.local +!.env.example + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Testing +coverage/ +.nyc_output/ + +# Solana +.anchor/ +target/ +**/*.rs.bk +Cargo.lock + +# Database +*.db +*.sqlite +*.sqlite3 + +# Docker volumes +postgres_data/ +redis_data/ + +# SSL certificates (add your own) +docker/nginx/ssl/*.pem +docker/nginx/ssl/*.key +docker/nginx/ssl/*.crt + +# Misc +.cache/ +temp/ +tmp/ +*.pid +*.seed +*.pid.lock + +# Pi-lens cache +.pi-lens/ + +# Keep directory structure +!.gitkeep diff --git a/INFRASTRUCTURE-SUMMARY.md b/INFRASTRUCTURE-SUMMARY.md new file mode 100644 index 0000000..ef97dd3 --- /dev/null +++ b/INFRASTRUCTURE-SUMMARY.md @@ -0,0 +1,168 @@ +# CoopCredits Infrastructure Configuration Summary + +## Your Server Infrastructure + +| Service | IP Address | Port | Integration Role | +|----------|----------------|------|--------------------------------| +| Plex | 172.20.1.220 | 32400| User authentication, content | +| Overseer | 172.20.1.225 | 5055 | Content requests, $COOP spend | +| Tautulli | 172.20.1.255 | 8181 | Watch tracking, $COOP earn | +| Website | coop.hobokenchicken.com | 443 | User dashboard, admin panel | + +## Files Created/Updated + +### Configuration Files +- `.env.production` - Production environment template +- `docker-compose.prod.yml` - Production Docker orchestration +- `docker/nginx/nginx.prod.conf` - Nginx reverse proxy config + +### Deployment Scripts +- `deployment/setup-infrastructure.sh` - Initial infrastructure setup +- `deployment/deploy-production.sh` - Production deployment +- `deployment/health-check.sh` - Service health monitoring + +### Documentation +- `docs/SETUP-INFRASTRUCTURE.md` - Step-by-step setup guide +- `docs/INFRASTRUCTURE.md` - Architecture and network documentation + +## Network Architecture + +``` +Internet + │ HTTPS + ▼ +┌─────────────┐ +│ Nginx │ (80/443) - SSL termination, rate limiting +└──────┬──────┘ + │ + ┌───┴───┐ + ▼ ▼ +┌──────┐ ┌──────┐ +│Frontend│ │Backend│ +│:3000 │ │:3001 │ +└──────┘ └───┬───┘ + │ + ┌──────┼──────┐ + ▼ ▼ ▼ +┌────────┐ ┌────┐ ┌──────┐ +│PostgreSQL│ │Redis│ │External│ +│:5432 │ │:6379│ │Network │ +└────────┘ └────┘ └───┬───┘ + │ + ┌─────────────┼─────────────┐ + ▼ ▼ ▼ + ┌─────────┐ ┌─────────┐ ┌─────────┐ + │ Plex │ │Tautulli │ │ Overseer│ + │172.20.1.│ │172.20.1.│ │172.20.1.│ + │ 220 │ │ 255 │ │ 225 │ + └─────────┘ └─────────┘ └─────────┘ +``` + +## Quick Deployment Commands + +```bash +# 1. Setup infrastructure +npm run setup:infra + +# 2. Edit .env with your API keys +nano .env + +# 3. Setup Solana +npm run setup:solana + +# 4. Deploy +npm run deploy:prod + +# 5. Check health +npm run health + +# 6. Watch mode monitoring +npm run health -- --watch +``` + +## Integration Points + +### Tautulli → CoopCredits +- **Trigger**: Watch events +- **Webhook URL**: `https://coop.hobokenchicken.com/webhooks/tautulli` +- **Action**: Mint $COOP tokens + +### Overseer → CoopCredits +- **Trigger**: Request approval/decline +- **Webhook URL**: `https://coop.hobokenchicken.com/webhooks/overseer` +- **Action**: Burn/spend $COOP tokens + +### Plex → CoopCredits +- **Trigger**: User login +- **Method**: OAuth via plex.tv +- **Action**: Authenticate users + +## Security Features + +1. **SSL/TLS**: Let's Encrypt or custom certificates +2. **Rate Limiting**: Nginx level protection +3. **Firewall**: UFW rules for local network +4. **Secrets**: Encrypted in `.env` file +5. **Wallet Keys**: AES-256-GCM encrypted in database +6. **CORS**: Configured for your domain +7. **JWT**: Secure session tokens + +## Monitoring + +```bash +# Health check +./deployment/health-check.sh + +# Watch mode (continuous) +./deployment/health-check.sh --watch + +# Docker logs +docker-compose -f docker-compose.prod.yml logs -f + +# Specific service +docker-compose -f docker-compose.prod.yml logs -f backend +``` + +## Backup Strategy + +```bash +# Database backup +docker-compose -f docker-compose.prod.yml exec -T postgres pg_dump -U coop coop_credits > backup_$(date +%Y%m%d).sql + +# Environment backup +cp .env .env.backup.$(date +%Y%m%d) + +# Wallet keys (secure offsite storage) +# - Solana mint authority keypair +# - Encryption key from .env +``` + +## Next Steps + +1. **Run setup**: `npm run setup:infra` +2. **Get API keys**: + - Tautulli: http://172.20.1.255:8181 → Settings → API + - Overseer: http://172.20.1.225:5055 → Settings → General + - Plex: https://plex.tv/claim +3. **Edit .env** with your keys +4. **Setup Solana**: `npm run setup:solana` +5. **Deploy**: `npm run deploy:prod` +6. **Configure webhooks** in Tautulli and Overseer +7. **Test**: `npm run health` + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| Cannot reach local services | Check Docker network: `docker network ls` | +| Webhook not received | Check Nginx logs: `docker/nginx/logs/access.log` | +| Database connection failed | Verify `.env` DATABASE_URL | +| SSL error | Check certificate paths in nginx config | +| CORS errors | Verify CORS_ORIGINS in `.env` | + +## Support Resources + +- **Setup Guide**: `docs/SETUP-INFRASTRUCTURE.md` +- **Architecture**: `docs/INFRASTRUCTURE.md` +- **Health Check**: `npm run health` +- **Logs**: `npm run docker:prod:logs` diff --git a/README.md b/README.md index e69de29..d3cf676 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,81 @@ +# CoopCredits ($COOP) Media Rewards Ecosystem + +A complete Solana-based rewards system integrated with Plex, Tautulli, and Overseer. + +## System Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ coop.hobokenchicken.com │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │ +│ │ Next.js │ │ Express │ │ PostgreSQL │ │ Solana Devnet │ │ +│ │ Frontend │◄─┤ API │◄─┤ Database │◄─┤ $COOP SPL │ │ +│ │ │ │ │ │ │ │ Token │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ └─────────────────┘ │ +│ ▲ ▲ │ +│ │ │ │ +│ Plex OAuth Tautulli Webhooks │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Plex Server │ │ Tautulli │ │ Overseer │ │ +│ │ (Content) │ │ (Analytics) │ │ (Requests) │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +## Quick Start + +```bash +# 1. Clone and install dependencies +cd coop-credits +npm install + +# 2. Set up environment +cp backend/.env.example backend/.env +# Edit backend/.env with your values + +# 3. Start with Docker Compose +docker-compose up -d + +# 4. Deploy Solana program +cd anchor-program +anchor deploy + +# 5. Seed initial data +npm run db:seed +``` + +## Project Structure + +``` +coop-credits/ +├── anchor-program/ # Solana Anchor program for $COOP token +├── backend/ # Express API server +├── frontend/ # Next.js dashboard +├── shared/ # Shared types and utilities +├── docker/ # Docker configurations +├── deployment/ # Deployment scripts +└── docker-compose.yml # Full stack orchestration +``` + +## User Flow + +1. **Login** → User visits coop.hobokenchicken.com, authenticates with Plex +2. **Wallet** → Auto-created Solana wallet or connect existing +3. **Watch** → Viewing on Plex → Tautulli triggers → $COOP minted +4. **Earn** → Real-time balance updates on dashboard +5. **Spend** → Request content on Overseer → $COOP deducted +6. **History** → Full transaction history visible on dashboard + +## Admin Features + +- System overview and analytics +- User management and balance adjustments +- Minting rate controls +- Spending controls and promotions +- Emergency pause and recovery + +## Environment Variables + +See `backend/.env.example` and `frontend/.env.local.example` for full configuration. diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..746d0c5 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,195 @@ +# CoopCredits Setup Guide + +This guide will walk you through setting up the complete CoopCredits ecosystem. + +## Prerequisites + +- Node.js 20+ +- Docker & Docker Compose +- Solana CLI (for blockchain development) +- Anchor Framework (for Solana program deployment) + +## Quick Start + +### 1. Clone and Install + +```bash +git clone +cd coop-credits +npm run install:all +``` + +### 2. Environment Configuration + +```bash +# Copy example environment file +cp .env.example .env + +# Edit with your values +nano .env +``` + +Required environment variables: +- `DATABASE_URL` - PostgreSQL connection string +- `JWT_SECRET` - Random string for JWT signing +- `PLEX_CLIENT_ID` & `PLEX_CLIENT_SECRET` - From Plex.tv +- `TAUTULLI_API_KEY` - From Tautulli settings +- `OVERSEER_API_KEY` - From Overseer settings +- `SOLANA_MINT_AUTHORITY_KEYPAIR` - Will be generated in step 3 + +### 3. Solana Setup + +```bash +# Run the Solana setup script +npm run setup:solana + +# This will: +# - Install Solana CLI if needed +# - Create a devnet keypair +# - Request airdrop (2 SOL) +# - Output the private key for your .env file +``` + +Copy the `SOLANA_MINT_AUTHORITY_KEYPAIR` value into your `.env` file. + +### 4. Deploy Solana Program + +```bash +cd anchor-program + +# Build the program +anchor build + +# Deploy to devnet +anchor deploy + +# Note the Program ID and update .env SOLANA_PROGRAM_ID +``` + +### 5. Database Setup + +```bash +# Start PostgreSQL +docker-compose up -d postgres + +# Run migrations +cd backend +npx prisma migrate dev + +# Generate Prisma client +npx prisma generate +``` + +### 6. Start Development + +```bash +# Start all services +npm run dev + +# Or individually: +npm run dev:backend # API on port 3001 +npm run dev:frontend # Next.js on port 3000 +``` + +### 7. Configure Tautulli Webhook + +1. Open Tautulli Settings +2. Go to Notification Agents → Add Agent → Webhook +3. Configure: + - Webhook URL: `http://your-server:3001/webhooks/tautulli` + - Webhook Method: POST + - JSON Payload: See webhook template in Admin Dashboard +4. Enable "Notify on Watched" + +### 8. Production Deployment + +```bash +# Setup SSL certificates +mkdir -p docker/nginx/ssl +cp your-cert.pem docker/nginx/ssl/cert.pem +cp your-key.pem docker/nginx/ssl/key.pem + +# Deploy +npm run deploy +``` + +## Architecture Overview + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ User Browser │────▶│ Next.js (3000) │────▶│ Express (3001) │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ + ┌─────────────────┐ │ + │ PostgreSQL │◀──────────┘ + │ (Database) │ + └─────────────────┘ + ▲ + ┌─────────────────┐ + │ Solana Devnet │ + │ ($COOP Token) │ + └─────────────────┘ + ▲ + ┌───────────────────────┴───────────────────────┐ + │ │ +┌───────▼───────┐ ┌────────▼──────┐ +│ Tautulli │ │ Overseer │ +│ (Watch Events)│ │ (Requests) │ +└───────────────┘ └───────────────┘ +``` + +## User Flow + +1. **Login**: User authenticates with Plex OAuth +2. **Wallet**: Auto-created Solana wallet (or connect existing) +3. **Watch**: Viewing on Plex → Tautulli triggers webhook +4. **Earn**: Backend validates → Mints $COOP tokens +5. **Spend**: Request content on Overseer → Deducts $COOP + +## Admin Features + +Access `/admin` with an admin account to: +- View system analytics +- Manage users and grant bonuses +- Configure minting rates +- Pause/resume minting +- Monitor transactions + +## Troubleshooting + +### Database Connection Issues +```bash +# Reset database +docker-compose down -v +docker-compose up -d postgres +npx prisma migrate dev +``` + +### Solana Transaction Failures +```bash +# Check balance +solana balance + +# Request airdrop +solana airdrop 2 +``` + +### Tautulli Webhook Not Working +1. Check webhook URL is accessible +2. Verify `TAUTULLI_WEBHOOK_SECRET` matches +3. Check backend logs: `docker-compose logs backend` + +## Security Considerations + +1. **Private Keys**: Never commit `.env` files +2. **JWT Secret**: Use a strong random string (32+ chars) +3. **Encryption Key**: Use `openssl rand -base64 32` +4. **SSL**: Always use HTTPS in production +5. **Rate Limiting**: Nginx config includes rate limits + +## Support + +For issues or questions: +1. Check logs: `docker-compose logs -f` +2. Review environment variables +3. Verify all services are running: `docker-compose ps` diff --git a/anchor-program/Anchor.toml b/anchor-program/Anchor.toml new file mode 100644 index 0000000..d5d224a --- /dev/null +++ b/anchor-program/Anchor.toml @@ -0,0 +1,27 @@ +[features] +seeds = false +skip-lint = false + +[programs.localnet] +coop_credits = "CoopCreDits1111111111111111111111111111111" + +[programs.devnet] +coop_credits = "CoopCreDits1111111111111111111111111111111" + +[registry] +url = "https://api.apr.dev" + +[provider] +cluster = "devnet" +wallet = "~/.config/solana/id.json" + +[scripts] +test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts" + +[test] +startup_wait = 10000 + +[test.validator] +bind_address = "0.0.0.0" +url = "https://api.devnet.solana.com" +ledger = ".anchor/test-ledger" diff --git a/anchor-program/Cargo.lock b/anchor-program/Cargo.lock new file mode 100644 index 0000000..8873127 --- /dev/null +++ b/anchor-program/Cargo.lock @@ -0,0 +1,3596 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b613b8e1e3cf911a086f53f03bf286f52fd7a7258e4fa606f0ef220d39d8877" +dependencies = [ + "generic-array", +] + +[[package]] +name = "aes" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e8b47f52ea9bae42228d07ec09eb676433d7c4ed1ebdf0f1d1c29ed446f1ab8" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", + "opaque-debug", +] + +[[package]] +name = "aes-gcm-siv" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589c637f0e68c877bbd59a4599bbe849cac8e5f3e4b5a3ebae8f528cd218dcdc" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "polyval", + "subtle", + "zeroize", +] + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anchor-attribute-access-control" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5f619f1d04f53621925ba8a2e633ba5a6081f2ae14758cbb67f38fd823e0a3e" +dependencies = [ + "anchor-syn", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-account" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7f2a3e1df4685f18d12a943a9f2a7456305401af21a07c9fe076ef9ecd6e400" +dependencies = [ + "anchor-syn", + "bs58 0.5.1", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-constant" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9423945cb55627f0b30903288e78baf6f62c6c8ab28fb344b6b25f1ffee3dca7" +dependencies = [ + "anchor-syn", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-error" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93ed12720033cc3c3bf3cfa293349c2275cd5ab99936e33dd4bf283aaad3e241" +dependencies = [ + "anchor-syn", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-event" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eef4dc0371eba2d8c8b54794b0b0eb786a234a559b77593d6f80825b6d2c77a2" +dependencies = [ + "anchor-syn", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-program" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b18c4f191331e078d4a6a080954d1576241c29c56638783322a18d308ab27e4f" +dependencies = [ + "anchor-syn", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "anchor-derive-accounts" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de10d6e9620d3bcea56c56151cad83c5992f50d5960b3a9bebc4a50390ddc3c" +dependencies = [ + "anchor-syn", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "anchor-derive-serde" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e2e5be518ec6053d90a2a7f26843dbee607583c779e6c8395951b9739bdfbe" +dependencies = [ + "anchor-syn", + "borsh-derive-internal 0.10.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "anchor-derive-space" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ecc31d19fa54840e74b7a979d44bcea49d70459de846088a1d71e87ba53c419" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "anchor-lang" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35da4785497388af0553586d55ebdc08054a8b1724720ef2749d313494f2b8ad" +dependencies = [ + "anchor-attribute-access-control", + "anchor-attribute-account", + "anchor-attribute-constant", + "anchor-attribute-error", + "anchor-attribute-event", + "anchor-attribute-program", + "anchor-derive-accounts", + "anchor-derive-serde", + "anchor-derive-space", + "arrayref", + "base64 0.13.1", + "bincode", + "borsh 0.10.4", + "bytemuck", + "getrandom 0.2.17", + "solana-program 1.16.20", + "thiserror 1.0.69", +] + +[[package]] +name = "anchor-spl" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c4fd6e43b2ca6220d2ef1641539e678bfc31b6cc393cf892b373b5997b6a39a" +dependencies = [ + "anchor-lang", + "mpl-token-metadata", + "solana-program 1.16.20", + "spl-associated-token-account", + "spl-token", + "spl-token-2022", +] + +[[package]] +name = "anchor-syn" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9101b84702fed2ea57bd22992f75065da5648017135b844283a2f6d74f27825" +dependencies = [ + "anyhow", + "bs58 0.5.1", + "heck", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "syn 1.0.109", + "thiserror 1.0.69", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "ark-bn254" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a22f4561524cd949590d78d7d4c5df8f592430d221f7f3c9497bbafd8972120f" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-std", +] + +[[package]] +name = "ark-ec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defd9a439d56ac24968cca0571f598a61bc8c55f71d50a89cda591cb750670ba" +dependencies = [ + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", + "itertools", + "num-traits", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +dependencies = [ + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", + "derivative", + "digest 0.10.7", + "itertools", + "num-bigint", + "num-traits", + "paste", + "rustc_version", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-poly" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d320bfc44ee185d899ccbadfa8bc31aab923ce1558716e1997a1e74057fe86bf" +dependencies = [ + "ark-ff", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-serialize-derive", + "ark-std", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae3281bc6d0fd7e549af32b52511e1302185bd688fd3359fa36423346ff682ea" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-std" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +dependencies = [ + "num-traits", + "rand 0.8.5", +] + +[[package]] +name = "array-bytes" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ad284aeb45c13f2fb4f084de4a420ebf447423bdf9386c0540ce33cb3ef4b8c" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "assert_matches" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bitmaps" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2" +dependencies = [ + "typenum", +] + +[[package]] +name = "blake3" +version = "1.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", + "digest 0.11.2", +] + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d696c370c750c948ada61c69a0ee2cbbb9c50b1019ddb86d9317157a99c2cae" + +[[package]] +name = "borsh" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15bf3650200d8bffa99015595e10f1fbd17de07abbc25bb067da79e769939bfa" +dependencies = [ + "borsh-derive 0.9.3", + "hashbrown 0.11.2", +] + +[[package]] +name = "borsh" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115e54d64eb62cdebad391c19efc9dce4981c690c85a33a12199d99bb9546fee" +dependencies = [ + "borsh-derive 0.10.4", + "hashbrown 0.13.2", +] + +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "borsh-derive 1.6.1", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6441c552f230375d18e3cc377677914d2ca2b0d36e52129fe15450a2dce46775" +dependencies = [ + "borsh-derive-internal 0.9.3", + "borsh-schema-derive-internal 0.9.3", + "proc-macro-crate 0.1.5", + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "borsh-derive" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831213f80d9423998dd696e2c5345aba6be7a0bd8cd19e31c5243e13df1cef89" +dependencies = [ + "borsh-derive-internal 0.10.4", + "borsh-schema-derive-internal 0.10.4", + "proc-macro-crate 0.1.5", + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "borsh-derive" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +dependencies = [ + "once_cell", + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "borsh-derive-internal" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5449c28a7b352f2d1e592a8a28bf139bc71afb0764a14f3c02500935d8c44065" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "borsh-derive-internal" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65d6ba50644c98714aa2a70d13d7df3cd75cd2b523a2b452bf010443800976b3" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "borsh-schema-derive-internal" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdbd5696d8bfa21d53d9fe39a714a18538bad11492a42d066dbbc395fb1951c0" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "borsh-schema-derive-internal" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "276691d96f063427be83e6692b86148e488ebba9f48f77788724ca027ba3b6d4" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bs58" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "771fe0050b883fcc3ea2359b1a96bcfbc090b7116eae7c3c512c7a083fdf23d3" + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bv" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8834bb1d8ee5dc048ee3124f2c7c1afcc6bc9aed03f11e9dfd8c69470a5db340" +dependencies = [ + "feature-probe", + "serde", +] + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "console_log" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89f72f65e8501878b8a004d5a1afb780987e2ce2b4532c562e367a72c57499f" +dependencies = [ + "log", + "web-sys", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "coop-credits" +version = "1.0.0" +dependencies = [ + "anchor-lang", + "anchor-spl", + "solana-program 1.16.20", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "crypto-mac" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b584a330336237c1eecd3e94266efb216c56ed91225d634cb2991c5f3fd1aeab" +dependencies = [ + "generic-array", + "subtle", +] + +[[package]] +name = "ctr" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "049bb91fb4aaf0e3c7efa6cd5ef877dbbbd15b39dad06d9948de4ec8a75761ea" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f9d052967f590a76e62eb387bd0bbb1b000182c3cefe5364db6b7211651bc0" +dependencies = [ + "byteorder", + "digest 0.9.0", + "rand_core 0.5.1", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rand_core 0.6.4", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derivation-path" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e5c37193a1db1d8ed868c03ec7b152175f26160a5b740e5e484143877e0adf0" + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.1", + "ctutils", +] + +[[package]] +name = "ed25519" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91cff35c70bba8a626e3185d8cd48cc11b5437e1a5bcd15b9b5fa3c64b6dfee7" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c762bae6dcaf24c4c84667b8579785430908723d5c889f469d76a41d59cc7a9d" +dependencies = [ + "curve25519-dalek 3.2.1", + "ed25519", + "rand 0.7.3", + "serde", + "sha2 0.9.9", + "zeroize", +] + +[[package]] +name = "ed25519-dalek-bip32" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d2be62a4061b872c8c0873ee4fc6f101ce7b889d039f019c5fa2af471a59908" +dependencies = [ + "derivation-path", + "ed25519-dalek", + "hmac 0.12.1", + "sha2 0.10.9", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "env_logger" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a12e6657c4c97ebab115a42dcee77225f7f482cdd841cf7088c657a42e9e00e7" +dependencies = [ + "atty", + "humantime", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "feature-probe" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835a3dc7d1ec9e75e2b5fb4ba75396837112d2060b03f7d43bc1897c7f7211da" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "five8" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75b8549488b4715defcb0d8a8a1c1c76a80661b5fa106b4ca0e7fce59d7d875" +dependencies = [ + "five8_core", +] + +[[package]] +name = "five8_const" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26dec3da8bc3ef08f2c04f61eab298c3ab334523e55f076354d6d6f613799a7b" +dependencies = [ + "five8_core", +] + +[[package]] +name = "five8_core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2551bf44bc5f776c15044b9b94153a00198be06743e262afaaa61f11ac7523a5" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "serde", + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hashbrown" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash 0.8.12", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hmac" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "126888268dcc288495a26bf004b38c5fdbb31682f992c84ceb046a1f0fe38840" +dependencies = [ + "crypto-mac", + "digest 0.9.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac-drbg" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17ea0a1394df5b6574da6e0c1ade9e78868c9fb0a4e5ef4428e32da4676b85b1" +dependencies = [ + "digest 0.9.0", + "generic-array", + "hmac 0.8.1", +] + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "im" +version = "15.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0acd33ff0285af998aaf9b57342af478078f53492322fafc47450e09397e0e9" +dependencies = [ + "bitmaps", + "rand_core 0.6.4", + "rand_xoshiro", + "rayon", + "serde", + "sized-chunks", + "typenum", + "version_check", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "libsecp256k1" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9d220bc1feda2ac231cb78c3d26f27676b8cf82c96971f7aeef3d0cf2797c73" +dependencies = [ + "arrayref", + "base64 0.12.3", + "digest 0.9.0", + "hmac-drbg", + "libsecp256k1-core", + "libsecp256k1-gen-ecmult", + "libsecp256k1-gen-genmult", + "rand 0.7.3", + "serde", + "sha2 0.9.9", + "typenum", +] + +[[package]] +name = "libsecp256k1-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f6ab710cec28cef759c5f18671a27dae2a5f952cdaaee1d8e2908cb2478a80" +dependencies = [ + "crunchy", + "digest 0.9.0", + "subtle", +] + +[[package]] +name = "libsecp256k1-gen-ecmult" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccab96b584d38fac86a83f07e659f0deafd0253dc096dab5a36d53efe653c5c3" +dependencies = [ + "libsecp256k1-core", +] + +[[package]] +name = "libsecp256k1-gen-genmult" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67abfe149395e3aa1c48a2beb32b068e2334402df8181f818d3aee2b304c4f5d" +dependencies = [ + "libsecp256k1-core", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmap2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83faa42c0a078c393f6b29d5db232d8be22776a891f8f56e5284faee4a20b327" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "merlin" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" +dependencies = [ + "byteorder", + "keccak", + "rand_core 0.6.4", + "zeroize", +] + +[[package]] +name = "mpl-token-metadata" +version = "3.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8ee05284d79b367ae8966d558e1a305a781fc80c9df51f37775169117ba64f" +dependencies = [ + "borsh 0.10.4", + "num-derive 0.3.3", + "num-traits", + "solana-program 1.16.20", + "thiserror 1.0.69", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a015b430d3c108a207fd776d2e2196aaf8b1cf8cf93253e3a097ff3085076a1" +dependencies = [ + "num_enum_derive 0.6.1", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive 0.7.6", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96667db765a921f7b295ffee8b60472b686a51d4f21c2ee4ffdb94c7013b65a6" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pbkdf2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216eaa586a190f0a738f2f918511eecfa90f13295abec0e457cdebcceda80cbd" +dependencies = [ + "crypto-mac", +] + +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "polyval" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8419d2b623c7c0896ff2d5d96e2cb4ede590fed28fcc34934f4c33c036e620a1" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6ea3c4595b96363c13943497db34af4460fb474a95c43f4446ad341b8c9785" +dependencies = [ + "toml", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.11+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "qstring" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d464fae65fff2680baf48019211ce37aaec0c78e9264c84a3e484717f965104e" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_xoshiro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_with" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07ff71d2c147a7b57362cead5e22f772cd52f6ab31cfcd9edcd7f6aeb2a0afbe" +dependencies = [ + "serde", + "serde_with_macros", +] + +[[package]] +name = "serde_with_macros" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "881b6f881b17d13214e5d494c939ebab463d01264ce1811e9d4ac3a882e7695f" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha3" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f81199417d4e5de3f04b1e871023acea7389672c4135918f05aa9cbf2f2fa809" +dependencies = [ + "block-buffer 0.9.0", + "digest 0.9.0", + "keccak", + "opaque-debug", +] + +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest 0.10.7", + "keccak", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" + +[[package]] +name = "sized-chunks" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e" +dependencies = [ + "bitmaps", + "typenum", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "solana-account" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f949fe4edaeaea78c844023bfc1c898e0b1f5a100f8a8d2d0f85d0a7b090258" +dependencies = [ + "solana-account-info", + "solana-clock", + "solana-instruction", + "solana-pubkey", + "solana-sdk-ids", +] + +[[package]] +name = "solana-account-info" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8f5152a288ef1912300fc6efa6c2d1f9bb55d9398eb6c72326360b8063987da" +dependencies = [ + "bincode", + "serde", + "solana-program-error", + "solana-program-memory", + "solana-pubkey", +] + +[[package]] +name = "solana-address-lookup-table-interface" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1673f67efe870b64a65cb39e6194be5b26527691ce5922909939961a6e6b395" +dependencies = [ + "bincode", + "bytemuck", + "serde", + "serde_derive", + "solana-clock", + "solana-instruction", + "solana-pubkey", + "solana-sdk-ids", + "solana-slot-hashes", +] + +[[package]] +name = "solana-atomic-u64" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52e52720efe60465b052b9e7445a01c17550666beec855cce66f44766697bc2" +dependencies = [ + "parking_lot", +] + +[[package]] +name = "solana-big-mod-exp" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75db7f2bbac3e62cfd139065d15bcda9e2428883ba61fc8d27ccb251081e7567" +dependencies = [ + "num-bigint", + "num-traits", + "solana-define-syscall", +] + +[[package]] +name = "solana-bincode" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a3787b8cf9c9fe3dd360800e8b70982b9e5a8af9e11c354b6665dd4a003adc" +dependencies = [ + "bincode", + "serde", + "solana-instruction", +] + +[[package]] +name = "solana-blake3-hasher" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a0801e25a1b31a14494fc80882a036be0ffd290efc4c2d640bfcca120a4672" +dependencies = [ + "blake3", + "solana-define-syscall", + "solana-hash", + "solana-sanitize", +] + +[[package]] +name = "solana-borsh" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "718333bcd0a1a7aed6655aa66bef8d7fb047944922b2d3a18f49cbc13e73d004" +dependencies = [ + "borsh 0.10.4", + "borsh 1.6.1", +] + +[[package]] +name = "solana-clock" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8584296123df8fe229b95e2ebfd37ae637fe9db9b7d4dd677ac5a78e80dbfce" +dependencies = [ + "serde", + "serde_derive", + "solana-sdk-ids", + "solana-sdk-macro 2.2.1", + "solana-sysvar-id", +] + +[[package]] +name = "solana-cpi" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc71126edddc2ba014622fc32d0f5e2e78ec6c5a1e0eb511b85618c09e9ea11" +dependencies = [ + "solana-account-info", + "solana-define-syscall", + "solana-instruction", + "solana-program-error", + "solana-pubkey", + "solana-stable-layout", +] + +[[package]] +name = "solana-decode-error" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c781686a18db2f942e70913f7ca15dc120ec38dcab42ff7557db2c70c625a35" +dependencies = [ + "num-traits", +] + +[[package]] +name = "solana-define-syscall" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae3e2abcf541c8122eafe9a625d4d194b4023c20adde1e251f94e056bb1aee2" + +[[package]] +name = "solana-epoch-rewards" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b575d3dd323b9ea10bb6fe89bf6bf93e249b215ba8ed7f68f1a3633f384db7" +dependencies = [ + "serde", + "serde_derive", + "solana-hash", + "solana-sdk-ids", + "solana-sdk-macro 2.2.1", + "solana-sysvar-id", +] + +[[package]] +name = "solana-epoch-schedule" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fce071fbddecc55d727b1d7ed16a629afe4f6e4c217bc8d00af3b785f6f67ed" +dependencies = [ + "serde", + "serde_derive", + "solana-sdk-ids", + "solana-sdk-macro 2.2.1", + "solana-sysvar-id", +] + +[[package]] +name = "solana-example-mocks" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84461d56cbb8bb8d539347151e0525b53910102e4bced875d49d5139708e39d3" +dependencies = [ + "serde", + "serde_derive", + "solana-address-lookup-table-interface", + "solana-clock", + "solana-hash", + "solana-instruction", + "solana-keccak-hasher", + "solana-message", + "solana-nonce", + "solana-pubkey", + "solana-sdk-ids", + "solana-system-interface", + "thiserror 2.0.18", +] + +[[package]] +name = "solana-feature-gate-interface" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f5c5382b449e8e4e3016fb05e418c53d57782d8b5c30aa372fc265654b956d" +dependencies = [ + "bincode", + "serde", + "serde_derive", + "solana-account", + "solana-account-info", + "solana-instruction", + "solana-program-error", + "solana-pubkey", + "solana-rent", + "solana-sdk-ids", + "solana-system-interface", +] + +[[package]] +name = "solana-fee-calculator" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89bc408da0fb3812bc3008189d148b4d3e08252c79ad810b245482a3f70cd8d" +dependencies = [ + "log", + "serde", + "serde_derive", +] + +[[package]] +name = "solana-frozen-abi" +version = "1.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e77bfd59ad4e64c0f06fbcbe16d58f3a40bdbcc050fb78fc7134a55a5c290b9" +dependencies = [ + "ahash 0.8.12", + "blake3", + "block-buffer 0.10.4", + "bs58 0.4.0", + "bv", + "byteorder", + "cc", + "either", + "generic-array", + "getrandom 0.1.16", + "im", + "lazy_static", + "log", + "memmap2", + "once_cell", + "rand_core 0.6.4", + "rustc_version", + "serde", + "serde_bytes", + "serde_derive", + "serde_json", + "sha2 0.10.9", + "solana-frozen-abi-macro", + "subtle", + "thiserror 1.0.69", +] + +[[package]] +name = "solana-frozen-abi-macro" +version = "1.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "992b866b9f0510fd3c290afe6a37109ae8d15b74fa24e3fb6d164be2971ee94f" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "solana-hash" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b96e9f0300fa287b545613f007dfe20043d7812bee255f418c1eb649c93b63" +dependencies = [ + "borsh 1.6.1", + "bytemuck", + "bytemuck_derive", + "five8", + "js-sys", + "serde", + "serde_derive", + "solana-atomic-u64", + "solana-sanitize", + "wasm-bindgen", +] + +[[package]] +name = "solana-instruction" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bab5682934bd1f65f8d2c16f21cb532526fcc1a09f796e2cacdb091eee5774ad" +dependencies = [ + "bincode", + "borsh 1.6.1", + "getrandom 0.2.17", + "js-sys", + "num-traits", + "serde", + "serde_derive", + "serde_json", + "solana-define-syscall", + "solana-pubkey", + "wasm-bindgen", +] + +[[package]] +name = "solana-instructions-sysvar" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0e85a6fad5c2d0c4f5b91d34b8ca47118fc593af706e523cdbedf846a954f57" +dependencies = [ + "bitflags 2.11.1", + "solana-account-info", + "solana-instruction", + "solana-program-error", + "solana-pubkey", + "solana-sanitize", + "solana-sdk-ids", + "solana-serialize-utils", + "solana-sysvar-id", +] + +[[package]] +name = "solana-keccak-hasher" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7aeb957fbd42a451b99235df4942d96db7ef678e8d5061ef34c9b34cae12f79" +dependencies = [ + "sha3 0.10.8", + "solana-define-syscall", + "solana-hash", + "solana-sanitize", +] + +[[package]] +name = "solana-last-restart-slot" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6360ac2fdc72e7463565cd256eedcf10d7ef0c28a1249d261ec168c1b55cdd" +dependencies = [ + "serde", + "serde_derive", + "solana-sdk-ids", + "solana-sdk-macro 2.2.1", + "solana-sysvar-id", +] + +[[package]] +name = "solana-loader-v2-interface" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8ab08006dad78ae7cd30df8eea0539e207d08d91eaefb3e1d49a446e1c49654" +dependencies = [ + "serde", + "serde_bytes", + "serde_derive", + "solana-instruction", + "solana-pubkey", + "solana-sdk-ids", +] + +[[package]] +name = "solana-loader-v3-interface" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f7162a05b8b0773156b443bccd674ea78bb9aa406325b467ea78c06c99a63a2" +dependencies = [ + "serde", + "serde_bytes", + "serde_derive", + "solana-instruction", + "solana-pubkey", + "solana-sdk-ids", + "solana-system-interface", +] + +[[package]] +name = "solana-loader-v4-interface" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "706a777242f1f39a83e2a96a2a6cb034cb41169c6ecbee2cf09cb873d9659e7e" +dependencies = [ + "serde", + "serde_bytes", + "serde_derive", + "solana-instruction", + "solana-pubkey", + "solana-sdk-ids", + "solana-system-interface", +] + +[[package]] +name = "solana-logger" +version = "1.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0278658cd4fb5405932452bf20f7df496ce8b9e9cf66a7d1c621bbe3b01fe297" +dependencies = [ + "env_logger", + "lazy_static", + "log", +] + +[[package]] +name = "solana-message" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1796aabce376ff74bf89b78d268fa5e683d7d7a96a0a4e4813ec34de49d5314b" +dependencies = [ + "bincode", + "blake3", + "lazy_static", + "serde", + "serde_derive", + "solana-bincode", + "solana-hash", + "solana-instruction", + "solana-pubkey", + "solana-sanitize", + "solana-sdk-ids", + "solana-short-vec", + "solana-system-interface", + "solana-transaction-error", + "wasm-bindgen", +] + +[[package]] +name = "solana-msg" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f36a1a14399afaabc2781a1db09cb14ee4cc4ee5c7a5a3cfcc601811379a8092" +dependencies = [ + "solana-define-syscall", +] + +[[package]] +name = "solana-native-token" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61515b880c36974053dd499c0510066783f0cc6ac17def0c7ef2a244874cf4a9" + +[[package]] +name = "solana-nonce" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703e22eb185537e06204a5bd9d509b948f0066f2d1d814a6f475dafb3ddf1325" +dependencies = [ + "serde", + "serde_derive", + "solana-fee-calculator", + "solana-hash", + "solana-pubkey", + "solana-sha256-hasher", +] + +[[package]] +name = "solana-program" +version = "1.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa5ac2110c5b927d6114b2d4f32af7f749fde0e6fd8f34777407ce89d66630be" +dependencies = [ + "ark-bn254", + "ark-ec", + "ark-ff", + "ark-serialize", + "array-bytes", + "base64 0.21.7", + "bincode", + "bitflags 1.3.2", + "blake3", + "borsh 0.10.4", + "borsh 0.9.3", + "bs58 0.4.0", + "bv", + "bytemuck", + "cc", + "console_error_panic_hook", + "console_log", + "curve25519-dalek 3.2.1", + "getrandom 0.2.17", + "itertools", + "js-sys", + "lazy_static", + "libc", + "libsecp256k1", + "log", + "memoffset", + "num-bigint", + "num-derive 0.3.3", + "num-traits", + "parking_lot", + "rand 0.7.3", + "rand_chacha 0.2.2", + "rustc_version", + "rustversion", + "serde", + "serde_bytes", + "serde_derive", + "serde_json", + "sha2 0.10.9", + "sha3 0.10.8", + "solana-frozen-abi", + "solana-frozen-abi-macro", + "solana-sdk-macro 1.16.20", + "thiserror 1.0.69", + "tiny-bip39", + "wasm-bindgen", + "zeroize", +] + +[[package]] +name = "solana-program" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98eca145bd3545e2fbb07166e895370576e47a00a7d824e325390d33bf467210" +dependencies = [ + "bincode", + "blake3", + "borsh 0.10.4", + "borsh 1.6.1", + "bs58 0.5.1", + "bytemuck", + "console_error_panic_hook", + "console_log", + "getrandom 0.2.17", + "lazy_static", + "log", + "memoffset", + "num-bigint", + "num-derive 0.4.2", + "num-traits", + "rand 0.8.5", + "serde", + "serde_bytes", + "serde_derive", + "solana-account-info", + "solana-address-lookup-table-interface", + "solana-atomic-u64", + "solana-big-mod-exp", + "solana-bincode", + "solana-blake3-hasher", + "solana-borsh", + "solana-clock", + "solana-cpi", + "solana-decode-error", + "solana-define-syscall", + "solana-epoch-rewards", + "solana-epoch-schedule", + "solana-example-mocks", + "solana-feature-gate-interface", + "solana-fee-calculator", + "solana-hash", + "solana-instruction", + "solana-instructions-sysvar", + "solana-keccak-hasher", + "solana-last-restart-slot", + "solana-loader-v2-interface", + "solana-loader-v3-interface", + "solana-loader-v4-interface", + "solana-message", + "solana-msg", + "solana-native-token", + "solana-nonce", + "solana-program-entrypoint", + "solana-program-error", + "solana-program-memory", + "solana-program-option", + "solana-program-pack", + "solana-pubkey", + "solana-rent", + "solana-sanitize", + "solana-sdk-ids", + "solana-sdk-macro 2.2.1", + "solana-secp256k1-recover", + "solana-serde-varint", + "solana-serialize-utils", + "solana-sha256-hasher", + "solana-short-vec", + "solana-slot-hashes", + "solana-slot-history", + "solana-stable-layout", + "solana-stake-interface", + "solana-system-interface", + "solana-sysvar", + "solana-sysvar-id", + "solana-vote-interface", + "thiserror 2.0.18", + "wasm-bindgen", +] + +[[package]] +name = "solana-program-entrypoint" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32ce041b1a0ed275290a5008ee1a4a6c48f5054c8a3d78d313c08958a06aedbd" +dependencies = [ + "solana-account-info", + "solana-msg", + "solana-program-error", + "solana-pubkey", +] + +[[package]] +name = "solana-program-error" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ee2e0217d642e2ea4bee237f37bd61bb02aec60da3647c48ff88f6556ade775" +dependencies = [ + "borsh 1.6.1", + "num-traits", + "serde", + "serde_derive", + "solana-decode-error", + "solana-instruction", + "solana-msg", + "solana-pubkey", +] + +[[package]] +name = "solana-program-memory" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a5426090c6f3fd6cfdc10685322fede9ca8e5af43cd6a59e98bfe4e91671712" +dependencies = [ + "solana-define-syscall", +] + +[[package]] +name = "solana-program-option" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc677a2e9bc616eda6dbdab834d463372b92848b2bfe4a1ed4e4b4adba3397d0" + +[[package]] +name = "solana-program-pack" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "319f0ef15e6e12dc37c597faccb7d62525a509fec5f6975ecb9419efddeb277b" +dependencies = [ + "solana-program-error", +] + +[[package]] +name = "solana-pubkey" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b62adb9c3261a052ca1f999398c388f1daf558a1b492f60a6d9e64857db4ff1" +dependencies = [ + "borsh 0.10.4", + "borsh 1.6.1", + "bytemuck", + "bytemuck_derive", + "curve25519-dalek 4.1.3", + "five8", + "five8_const", + "getrandom 0.2.17", + "js-sys", + "num-traits", + "serde", + "serde_derive", + "solana-atomic-u64", + "solana-decode-error", + "solana-define-syscall", + "solana-sanitize", + "solana-sha256-hasher", + "wasm-bindgen", +] + +[[package]] +name = "solana-rent" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1aea8fdea9de98ca6e8c2da5827707fb3842833521b528a713810ca685d2480" +dependencies = [ + "serde", + "serde_derive", + "solana-sdk-ids", + "solana-sdk-macro 2.2.1", + "solana-sysvar-id", +] + +[[package]] +name = "solana-sanitize" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f1bc1357b8188d9c4a3af3fc55276e56987265eb7ad073ae6f8180ee54cecf" + +[[package]] +name = "solana-sdk" +version = "1.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbe17a1ce6082979e7beffb7cadd7051e29d873594622a11a7d0a4c2dd4b7934" +dependencies = [ + "assert_matches", + "base64 0.21.7", + "bincode", + "bitflags 1.3.2", + "borsh 0.10.4", + "bs58 0.4.0", + "bytemuck", + "byteorder", + "chrono", + "derivation-path", + "digest 0.10.7", + "ed25519-dalek", + "ed25519-dalek-bip32", + "generic-array", + "hmac 0.12.1", + "itertools", + "js-sys", + "lazy_static", + "libsecp256k1", + "log", + "memmap2", + "num-derive 0.3.3", + "num-traits", + "num_enum 0.6.1", + "pbkdf2 0.11.0", + "qstring", + "rand 0.7.3", + "rand_chacha 0.2.2", + "rustc_version", + "rustversion", + "serde", + "serde_bytes", + "serde_derive", + "serde_json", + "serde_with", + "sha2 0.10.9", + "sha3 0.10.8", + "solana-frozen-abi", + "solana-frozen-abi-macro", + "solana-logger", + "solana-program 1.16.20", + "solana-sdk-macro 1.16.20", + "thiserror 1.0.69", + "uriparse", + "wasm-bindgen", +] + +[[package]] +name = "solana-sdk-ids" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5d8b9cc68d5c88b062a33e23a6466722467dde0035152d8fb1afbcdf350a5f" +dependencies = [ + "solana-pubkey", +] + +[[package]] +name = "solana-sdk-macro" +version = "1.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fe4363d2503a75325ec94aa18b063574edb3454d38840e01c5af477b3b0689d" +dependencies = [ + "bs58 0.4.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + +[[package]] +name = "solana-sdk-macro" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86280da8b99d03560f6ab5aca9de2e38805681df34e0bb8f238e69b29433b9df" +dependencies = [ + "bs58 0.5.1", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "solana-secp256k1-recover" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa3120b6cdaa270f39444f5093a90a7b03d296d362878f7a6991d6de3bbe496" +dependencies = [ + "libsecp256k1", + "solana-define-syscall", + "thiserror 2.0.18", +] + +[[package]] +name = "solana-serde-varint" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a7e155eba458ecfb0107b98236088c3764a09ddf0201ec29e52a0be40857113" +dependencies = [ + "serde", +] + +[[package]] +name = "solana-serialize-utils" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "817a284b63197d2b27afdba829c5ab34231da4a9b4e763466a003c40ca4f535e" +dependencies = [ + "solana-instruction", + "solana-pubkey", + "solana-sanitize", +] + +[[package]] +name = "solana-sha256-hasher" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa3feb32c28765f6aa1ce8f3feac30936f16c5c3f7eb73d63a5b8f6f8ecdc44" +dependencies = [ + "sha2 0.10.9", + "solana-define-syscall", + "solana-hash", +] + +[[package]] +name = "solana-short-vec" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c54c66f19b9766a56fa0057d060de8378676cb64987533fa088861858fc5a69" +dependencies = [ + "serde", +] + +[[package]] +name = "solana-slot-hashes" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8691982114513763e88d04094c9caa0376b867a29577939011331134c301ce" +dependencies = [ + "serde", + "serde_derive", + "solana-hash", + "solana-sdk-ids", + "solana-sysvar-id", +] + +[[package]] +name = "solana-slot-history" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ccc1b2067ca22754d5283afb2b0126d61eae734fc616d23871b0943b0d935e" +dependencies = [ + "bv", + "serde", + "serde_derive", + "solana-sdk-ids", + "solana-sysvar-id", +] + +[[package]] +name = "solana-stable-layout" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f14f7d02af8f2bc1b5efeeae71bc1c2b7f0f65cd75bcc7d8180f2c762a57f54" +dependencies = [ + "solana-instruction", + "solana-pubkey", +] + +[[package]] +name = "solana-stake-interface" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5269e89fde216b4d7e1d1739cf5303f8398a1ff372a81232abbee80e554a838c" +dependencies = [ + "borsh 0.10.4", + "borsh 1.6.1", + "num-traits", + "serde", + "serde_derive", + "solana-clock", + "solana-cpi", + "solana-decode-error", + "solana-instruction", + "solana-program-error", + "solana-pubkey", + "solana-system-interface", + "solana-sysvar-id", +] + +[[package]] +name = "solana-system-interface" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94d7c18cb1a91c6be5f5a8ac9276a1d7c737e39a21beba9ea710ab4b9c63bc90" +dependencies = [ + "js-sys", + "num-traits", + "serde", + "serde_derive", + "solana-decode-error", + "solana-instruction", + "solana-pubkey", + "wasm-bindgen", +] + +[[package]] +name = "solana-sysvar" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c3595f95069f3d90f275bb9bd235a1973c4d059028b0a7f81baca2703815db" +dependencies = [ + "base64 0.22.1", + "bincode", + "bytemuck", + "bytemuck_derive", + "lazy_static", + "serde", + "serde_derive", + "solana-account-info", + "solana-clock", + "solana-define-syscall", + "solana-epoch-rewards", + "solana-epoch-schedule", + "solana-fee-calculator", + "solana-hash", + "solana-instruction", + "solana-instructions-sysvar", + "solana-last-restart-slot", + "solana-program-entrypoint", + "solana-program-error", + "solana-program-memory", + "solana-pubkey", + "solana-rent", + "solana-sanitize", + "solana-sdk-ids", + "solana-sdk-macro 2.2.1", + "solana-slot-hashes", + "solana-slot-history", + "solana-stake-interface", + "solana-sysvar-id", +] + +[[package]] +name = "solana-sysvar-id" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5762b273d3325b047cfda250787f8d796d781746860d5d0a746ee29f3e8812c1" +dependencies = [ + "solana-pubkey", + "solana-sdk-ids", +] + +[[package]] +name = "solana-transaction-error" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "222a9dc8fdb61c6088baab34fc3a8b8473a03a7a5fd404ed8dd502fa79b67cb1" +dependencies = [ + "solana-instruction", + "solana-sanitize", +] + +[[package]] +name = "solana-vote-interface" +version = "2.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b80d57478d6599d30acc31cc5ae7f93ec2361a06aefe8ea79bc81739a08af4c3" +dependencies = [ + "bincode", + "num-derive 0.4.2", + "num-traits", + "serde", + "serde_derive", + "solana-clock", + "solana-decode-error", + "solana-hash", + "solana-instruction", + "solana-pubkey", + "solana-rent", + "solana-sdk-ids", + "solana-serde-varint", + "solana-serialize-utils", + "solana-short-vec", + "solana-system-interface", +] + +[[package]] +name = "solana-zk-token-sdk" +version = "1.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c83eec033c30c95938905374292fb8a3559dd3dfb36d715624e5f8f41b078e" +dependencies = [ + "aes-gcm-siv", + "base64 0.21.7", + "bincode", + "bytemuck", + "byteorder", + "curve25519-dalek 3.2.1", + "getrandom 0.1.16", + "itertools", + "lazy_static", + "merlin", + "num-derive 0.3.3", + "num-traits", + "rand 0.7.3", + "serde", + "serde_json", + "sha3 0.9.1", + "solana-program 1.16.20", + "solana-sdk", + "subtle", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "spl-associated-token-account" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385e31c29981488f2820b2022d8e731aae3b02e6e18e2fd854e4c9a94dc44fc3" +dependencies = [ + "assert_matches", + "borsh 0.10.4", + "num-derive 0.4.2", + "num-traits", + "solana-program 1.16.20", + "spl-token", + "spl-token-2022", + "thiserror 1.0.69", +] + +[[package]] +name = "spl-discriminator" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cce5d563b58ef1bb2cdbbfe0dfb9ffdc24903b10ae6a4df2d8f425ece375033f" +dependencies = [ + "bytemuck", + "solana-program 1.16.20", + "spl-discriminator-derive", +] + +[[package]] +name = "spl-discriminator-derive" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07fd7858fc4ff8fb0e34090e41d7eb06a823e1057945c26d480bfc21d2338a93" +dependencies = [ + "quote", + "spl-discriminator-syn", + "syn 2.0.117", +] + +[[package]] +name = "spl-discriminator-syn" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18fea7be851bd98d10721782ea958097c03a0c2a07d8d4997041d0ece6319a63" +dependencies = [ + "proc-macro2", + "quote", + "sha2 0.10.9", + "syn 2.0.117", + "thiserror 1.0.69", +] + +[[package]] +name = "spl-memo" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64f13e674ae639249a78e2445fb043cf70e18f60e6dcf87a5411bc8c9580f130" +dependencies = [ + "solana-program 2.3.0", +] + +[[package]] +name = "spl-pod" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2881dddfca792737c0706fa0175345ab282b1b0879c7d877bad129645737c079" +dependencies = [ + "borsh 0.10.4", + "bytemuck", + "solana-program 1.16.20", + "solana-zk-token-sdk", + "spl-program-error", +] + +[[package]] +name = "spl-program-error" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "249e0318493b6bcf27ae9902600566c689b7dfba9f1bdff5893e92253374e78c" +dependencies = [ + "num-derive 0.4.2", + "num-traits", + "solana-program 1.16.20", + "spl-program-error-derive", + "thiserror 1.0.69", +] + +[[package]] +name = "spl-program-error-derive" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1845dfe71fd68f70382232742e758557afe973ae19e6c06807b2c30f5d5cb474" +dependencies = [ + "proc-macro2", + "quote", + "sha2 0.10.9", + "syn 2.0.117", +] + +[[package]] +name = "spl-tlv-account-resolution" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "062e148d3eab7b165582757453632ffeef490c02c86a48bfdb4988f63eefb3b9" +dependencies = [ + "bytemuck", + "solana-program 1.16.20", + "spl-discriminator", + "spl-pod", + "spl-program-error", + "spl-type-length-value", +] + +[[package]] +name = "spl-token" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e9e171cbcb4b1f72f6d78ed1e975cb467f56825c27d09b8dd2608e4e7fc8b3b" +dependencies = [ + "arrayref", + "bytemuck", + "num-derive 0.4.2", + "num-traits", + "num_enum 0.7.6", + "solana-program 2.3.0", + "thiserror 1.0.69", +] + +[[package]] +name = "spl-token-2022" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4abf34a65ba420584a0c35f3903f8d727d1f13ababbdc3f714c6b065a686e86" +dependencies = [ + "arrayref", + "bytemuck", + "num-derive 0.4.2", + "num-traits", + "num_enum 0.7.6", + "solana-program 1.16.20", + "solana-zk-token-sdk", + "spl-memo", + "spl-pod", + "spl-token", + "spl-token-metadata-interface", + "spl-transfer-hook-interface", + "spl-type-length-value", + "thiserror 1.0.69", +] + +[[package]] +name = "spl-token-metadata-interface" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c16ce3ba6979645fb7627aa1e435576172dd63088dc7848cb09aa331fa1fe4f" +dependencies = [ + "borsh 0.10.4", + "solana-program 1.16.20", + "spl-discriminator", + "spl-pod", + "spl-program-error", + "spl-type-length-value", +] + +[[package]] +name = "spl-transfer-hook-interface" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051d31803f873cabe71aec3c1b849f35248beae5d19a347d93a5c9cccc5d5a9b" +dependencies = [ + "arrayref", + "bytemuck", + "solana-program 1.16.20", + "spl-discriminator", + "spl-pod", + "spl-program-error", + "spl-tlv-account-resolution", + "spl-type-length-value", +] + +[[package]] +name = "spl-type-length-value" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a468e6f6371f9c69aae760186ea9f1a01c2908351b06a5e0026d21cfc4d7ecac" +dependencies = [ + "bytemuck", + "solana-program 1.16.20", + "spl-discriminator", + "spl-pod", + "spl-program-error", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tiny-bip39" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc59cb9dfc85bb312c3a78fd6aa8a8582e310b0fa885d5bb877f6dcc601839d" +dependencies = [ + "anyhow", + "hmac 0.8.1", + "once_cell", + "pbkdf2 0.4.0", + "rand 0.7.3", + "rustc-hash", + "sha2 0.9.9", + "thiserror 1.0.69", + "unicode-normalization", + "wasm-bindgen", + "zeroize", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.1", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.1", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "universal-hash" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f214e8f697e925001e66ec2c6e37a4ef93f0f78c2eed7814394e10c62025b05" +dependencies = [ + "generic-array", + "subtle", +] + +[[package]] +name = "uriparse" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0200d0fc04d809396c2ad43f3c95da3582a2556eba8d453c1087f4120ee352ff" +dependencies = [ + "fnv", + "lazy_static", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zeroize" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4756f7db3f7b5574938c3eb1c117038b8e07f95ee6718c0efad4ac21508f1efd" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/anchor-program/Cargo.toml b/anchor-program/Cargo.toml new file mode 100644 index 0000000..f397704 --- /dev/null +++ b/anchor-program/Cargo.toml @@ -0,0 +1,14 @@ +[workspace] +members = [ + "programs/*" +] +resolver = "2" + +[profile.release] +overflow-checks = true +lto = "fat" +codegen-units = 1 +[profile.release.build-override] +opt-level = 3 +incremental = false +codegen-units = 1 diff --git a/anchor-program/package.json b/anchor-program/package.json new file mode 100644 index 0000000..48c4c49 --- /dev/null +++ b/anchor-program/package.json @@ -0,0 +1,28 @@ +{ + "name": "coop-credits-anchor", + "version": "1.0.0", + "description": "CoopCredits SPL Token Program", + "scripts": { + "lint:fix": "prettier */*.js \"*/**/*{.js,.ts}\" -w", + "lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check", + "build": "anchor build", + "deploy": "anchor deploy", + "test": "anchor test", + "idl:export": "anchor idl init --filepath target/idl/coop_credits.json CoopCreDits1111111111111111111111111111111" + }, + "dependencies": { + "@coral-xyz/anchor": "^0.29.0", + "@solana/spl-token": "^0.3.9", + "@solana/web3.js": "^1.87.6" + }, + "devDependencies": { + "@types/bn.js": "^5.1.0", + "@types/chai": "^4.3.0", + "@types/mocha": "^9.0.0", + "chai": "^4.3.4", + "mocha": "^9.0.3", + "prettier": "^2.6.2", + "ts-mocha": "^10.0.0", + "typescript": "^4.3.5" + } +} diff --git a/anchor-program/programs/coop_credits/Cargo.toml b/anchor-program/programs/coop_credits/Cargo.toml new file mode 100644 index 0000000..935bc42 --- /dev/null +++ b/anchor-program/programs/coop_credits/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "coop-credits" +version = "1.0.0" +description = "CoopCredits SPL Token Program" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "coop_credits" + +[features] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +cpi = ["no-entrypoint"] +default = [] + +[dependencies] +anchor-lang = { version = "0.29.0", features = ["init-if-needed"] } +anchor-spl = { version = "0.29.0", features = ["metadata"] } +solana-program = "=1.16.20" diff --git a/anchor-program/programs/coop_credits/src/lib.rs b/anchor-program/programs/coop_credits/src/lib.rs new file mode 100644 index 0000000..42225e2 --- /dev/null +++ b/anchor-program/programs/coop_credits/src/lib.rs @@ -0,0 +1,251 @@ +use anchor_lang::prelude::*; +use anchor_spl::token::{self, Burn, Mint, MintTo, Token, TokenAccount}; + +declare_id!("CoopCredits111111111111111111111111111111111"); + +pub const DECIMALS: u8 = 6; + +#[program] +pub mod coop_credits { + use super::*; + + pub fn initialize(ctx: Context) -> Result<()> { + let state = &mut ctx.accounts.global_state; + state.authority = ctx.accounts.authority.key(); + state.mint = ctx.accounts.mint.key(); + state.total_minted = 0; + state.total_burned = 0; + state.paused = false; + state.rate = 10; + state.bump = ctx.bumps.global_state; + Ok(()) + } + + pub fn mint_to_user( + ctx: Context, + amount: u64, + session_id: String, + title: String, + minutes: u32, + ) -> Result<()> { + require!(!ctx.accounts.state.paused, ErrorCode::Paused); + require!( + ctx.accounts.authority.key() == ctx.accounts.state.authority, + ErrorCode::Unauthorized + ); + + let seeds = &[b"mint_auth", &[ctx.bumps.mint_auth]]; + let signer = &[&seeds[..]]; + + let cpi = MintTo { + mint: ctx.accounts.mint.to_account_info(), + to: ctx.accounts.user_token.to_account_info(), + authority: ctx.accounts.mint_auth.to_account_info(), + }; + + token::mint_to( + CpiContext::new_with_signer(ctx.accounts.token_prog.to_account_info(), cpi, signer), + amount, + )?; + + let state = &mut ctx.accounts.state; + state.total_minted = state.total_minted.saturating_add(amount); + + let tx = &mut ctx.accounts.tx_record; + tx.user = ctx.accounts.user_token.owner; + tx.tx_type = TxType::Earn; + tx.amount = amount; + tx.timestamp = Clock::get()?.unix_timestamp; + tx.session_id = session_id; + tx.title = title; + tx.minutes = minutes; + + msg!("Minted {}", amount); + Ok(()) + } + + pub fn burn_from_user( + ctx: Context, + amount: u64, + request_id: String, + title: String, + ) -> Result<()> { + require!(!ctx.accounts.state.paused, ErrorCode::Paused); + + let cpi = Burn { + mint: ctx.accounts.mint.to_account_info(), + from: ctx.accounts.user_token.to_account_info(), + authority: ctx.accounts.user.to_account_info(), + }; + + token::burn( + CpiContext::new(ctx.accounts.token_prog.to_account_info(), cpi), + amount, + )?; + + let state = &mut ctx.accounts.state; + state.total_burned = state.total_burned.saturating_add(amount); + + let tx = &mut ctx.accounts.tx_record; + tx.user = ctx.accounts.user.key(); + tx.tx_type = TxType::Spend; + tx.amount = amount; + tx.timestamp = Clock::get()?.unix_timestamp; + tx.session_id = request_id; + tx.title = title; + tx.minutes = 0; + + msg!("Burned {}", amount); + Ok(()) + } + + pub fn set_rate(ctx: Context, new_rate: u32) -> Result<()> { + require!( + ctx.accounts.authority.key() == ctx.accounts.state.authority, + ErrorCode::Unauthorized + ); + ctx.accounts.state.rate = new_rate; + Ok(()) + } + + pub fn pause(ctx: Context) -> Result<()> { + require!( + ctx.accounts.authority.key() == ctx.accounts.state.authority, + ErrorCode::Unauthorized + ); + ctx.accounts.state.paused = true; + Ok(()) + } + + pub fn resume(ctx: Context) -> Result<()> { + require!( + ctx.accounts.authority.key() == ctx.accounts.state.authority, + ErrorCode::Unauthorized + ); + ctx.accounts.state.paused = false; + Ok(()) + } +} + +#[derive(Accounts)] +pub struct Initialize<'info> { + #[account(init, payer = authority, space = 8 + State::SIZE, seeds = [b"state"], bump)] + pub global_state: Account<'info, State>, + + #[account(init, payer = authority, mint::decimals = DECIMALS, mint::authority = mint_auth)] + pub mint: Account<'info, Mint>, + + /// CHECK: PDA + #[account(seeds = [b"mint_auth"], bump)] + pub mint_auth: UncheckedAccount<'info>, + + #[account(mut)] + pub authority: Signer<'info>, + + pub system_program: Program<'info, System>, + pub token_prog: Program<'info, Token>, + pub rent: Sysvar<'info, Rent>, +} + +#[derive(Accounts)] +#[instruction(amount: u64, session_id: String, title: String, minutes: u32)] +pub struct MintToUser<'info> { + #[account(mut, seeds = [b"state"], bump = state.bump)] + pub state: Account<'info, State>, + + #[account(mut)] + pub mint: Account<'info, Mint>, + + /// CHECK: PDA + #[account(seeds = [b"mint_auth"], bump)] + pub mint_auth: UncheckedAccount<'info>, + + #[account(mut)] + pub user_token: Account<'info, TokenAccount>, + + #[account(init, payer = authority, space = 8 + TxRecord::SIZE, seeds = [b"tx", user_token.owner.as_ref(), &Clock::get()?.unix_timestamp.to_le_bytes()], bump)] + pub tx_record: Account<'info, TxRecord>, + + #[account(mut)] + pub authority: Signer<'info>, + + pub system_program: Program<'info, System>, + pub token_prog: Program<'info, Token>, +} + +#[derive(Accounts)] +#[instruction(amount: u64, request_id: String, title: String)] +pub struct BurnFromUser<'info> { + #[account(mut, seeds = [b"state"], bump = state.bump)] + pub state: Account<'info, State>, + + #[account(mut)] + pub mint: Account<'info, Mint>, + + #[account(mut)] + pub user_token: Account<'info, TokenAccount>, + + #[account(init, payer = authority, space = 8 + TxRecord::SIZE, seeds = [b"tx", user.key().as_ref(), &Clock::get()?.unix_timestamp.to_le_bytes()], bump)] + pub tx_record: Account<'info, TxRecord>, + + #[account(mut)] + pub authority: Signer<'info>, + + /// CHECK: User signer + #[account(mut)] + pub user: Signer<'info>, + + pub system_program: Program<'info, System>, + pub token_prog: Program<'info, Token>, +} + +#[derive(Accounts)] +pub struct Admin<'info> { + #[account(mut, seeds = [b"state"], bump = state.bump)] + pub state: Account<'info, State>, + pub authority: Signer<'info>, +} + +#[account] +pub struct State { + pub authority: Pubkey, + pub mint: Pubkey, + pub total_minted: u64, + pub total_burned: u64, + pub paused: bool, + pub rate: u32, + pub bump: u8, +} + +impl State { + pub const SIZE: usize = 32 + 32 + 8 + 8 + 1 + 4 + 1; +} + +#[account] +pub struct TxRecord { + pub user: Pubkey, + pub tx_type: TxType, + pub amount: u64, + pub timestamp: i64, + pub session_id: String, + pub title: String, + pub minutes: u32, +} + +impl TxRecord { + pub const SIZE: usize = 32 + 1 + 8 + 8 + 68 + 132 + 4; +} + +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, PartialEq)] +pub enum TxType { + Earn, + Spend, +} + +#[error_code] +pub enum ErrorCode { + #[msg("Paused")] + Paused, + #[msg("Unauthorized")] + Unauthorized, +} diff --git a/anchor-program/tests/coop-credits.ts b/anchor-program/tests/coop-credits.ts new file mode 100644 index 0000000..ca7ef91 --- /dev/null +++ b/anchor-program/tests/coop-credits.ts @@ -0,0 +1,95 @@ +import * as anchor from "@coral-xyz/anchor"; +import { Program } from "@coral-xyz/anchor"; +import { CoopCredits } from "../target/types/coop_credits"; +import { expect } from "chai"; + +describe("coop-credits", () => { + // Configure the client to use the local cluster. + anchor.setProvider(anchor.AnchorProvider.env()); + + const program = anchor.workspace.CoopCredits as Program; + const provider = anchor.getProvider(); + + let globalStatePDA: anchor.web3.PublicKey; + let mintPDA: anchor.web3.PublicKey; + let mintAuthorityPDA: anchor.web3.PublicKey; + + before(async () => { + // Find PDAs + [globalStatePDA] = anchor.web3.PublicKey.findProgramAddressSync( + [Buffer.from("global_state")], + program.programId, + ); + + [mintAuthorityPDA] = anchor.web3.PublicKey.findProgramAddressSync( + [Buffer.from("mint_authority")], + program.programId, + ); + }); + + it("Initializes the program", async () => { + const tx = await program.methods + .initialize() + .accounts({ + globalState: globalStatePDA, + authority: provider.publicKey, + systemProgram: anchor.web3.SystemProgram.programId, + tokenProgram: anchor.utils.token.TOKEN_PROGRAM_ID, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + }) + .rpc(); + + console.log("Initialize transaction signature", tx); + + // Verify global state + const globalState = await program.account.globalState.fetch(globalStatePDA); + expect(globalState.authority.toString()).to.equal( + provider.publicKey.toString(), + ); + expect(globalState.paused).to.be.false; + expect(globalState.creditsPerMinute).to.equal(10); + }); + + it("Updates minting rate", async () => { + const newRate = 20; + + const tx = await program.methods + .updateMintingRate(newRate) + .accounts({ + globalState: globalStatePDA, + authority: provider.publicKey, + }) + .rpc(); + + console.log("Update rate transaction signature", tx); + + const globalState = await program.account.globalState.fetch(globalStatePDA); + expect(globalState.creditsPerMinute).to.equal(newRate); + }); + + it("Pauses and resumes minting", async () => { + // Pause + let tx = await program.methods + .pauseMinting() + .accounts({ + globalState: globalStatePDA, + authority: provider.publicKey, + }) + .rpc(); + + let globalState = await program.account.globalState.fetch(globalStatePDA); + expect(globalState.paused).to.be.true; + + // Resume + tx = await program.methods + .resumeMinting() + .accounts({ + globalState: globalStatePDA, + authority: provider.publicKey, + }) + .rpc(); + + globalState = await program.account.globalState.fetch(globalStatePDA); + expect(globalState.paused).to.be.false; + }); +}); diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..b8a5d48 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,38 @@ +# Database +DATABASE_URL="postgresql://coop:coop_password@localhost:5432/coop_credits?schema=public" + +# Server +PORT=3001 +NODE_ENV=development +API_URL=http://localhost:3001 +FRONTEND_URL=http://localhost:3000 + +# JWT +JWT_SECRET=your-super-secret-jwt-key-change-this-in-production +JWT_EXPIRES_IN=7d + +# Plex OAuth +PLEX_CLIENT_ID=your-plex-client-id +PLEX_CLIENT_SECRET=your-plex-client-secret +PLEX_REDIRECT_URI=http://localhost:3000/auth/callback + +# Tautulli +TAUTULLI_URL=http://localhost:8181 +TAUTULLI_API_KEY=your-tautulli-api-key +TAUTULLI_WEBHOOK_SECRET=your-webhook-secret + +# Overseer +OVERSEER_URL=http://localhost:5055 +OVERSEER_API_KEY=your-overseer-api-key + +# Solana +SOLANA_RPC_URL=https://api.devnet.solana.com +SOLANA_PROGRAM_ID=CoopCredits111111111111111111111111111111111 +SOLANA_MINT_AUTHORITY_KEYPAIR=your-base58-encoded-keypair-for-minting +SOLANA_TOKEN_DECIMALS=6 + +# Redis (optional - for caching and pub/sub) +REDIS_URL=redis://localhost:6379 + +# Encryption (for wallet private keys) +ENCRYPTION_KEY=your-32-char-encryption-key!! diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..8066a4c --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,45 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ +COPY prisma ./prisma/ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Generate Prisma client +RUN npx prisma generate + +# Build TypeScript +RUN npm run build + +# Production stage +FROM node:20-alpine + +WORKDIR /app + +# Install OpenSSL for Prisma +RUN apk add --no-cache openssl + +# Copy package files +COPY package*.json ./ + +# Install production dependencies only +RUN npm ci --only=production + +# Copy built files from builder +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=builder /app/prisma ./prisma + +# Expose port +EXPOSE 3001 + +# Start application +CMD ["npm", "start"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..d14916f --- /dev/null +++ b/backend/package.json @@ -0,0 +1,49 @@ +{ + "name": "coop-credits-backend", + "version": "1.0.0", + "description": "CoopCredits API Server", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "db:generate": "prisma generate", + "db:migrate": "prisma migrate dev", + "db:seed": "tsx src/seed.ts", + "db:studio": "prisma studio", + "test": "vitest", + "lint": "eslint src --ext .ts" + }, + "dependencies": { + "@prisma/client": "^5.7.0", + "@solana/spl-token": "^0.3.9", + "@solana/web3.js": "^1.87.6", + "axios": "^1.6.2", + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "express-rate-limit": "^7.1.5", + "express-validator": "^7.0.1", + "helmet": "^7.1.0", + "ioredis": "^5.3.2", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.0", + "socket.io": "^4.7.3", + "tweetnacl": "^1.0.3", + "ws": "^8.15.1" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.5", + "@types/morgan": "^1.9.9", + "@types/node": "^20.10.5", + "@types/ws": "^8.5.10", + "prisma": "^5.7.0", + "tsx": "^4.7.0", + "typescript": "^5.3.3", + "vitest": "^1.1.0" + } +} diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..619b9fa --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,189 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(uuid()) + plexId String @unique @map("plex_id") + plexUsername String @map("plex_username") + email String? + isAdmin Boolean @default(false) @map("is_admin") + isActive Boolean @default(true) @map("is_active") + + // Solana wallet + walletAddress String? @unique @map("wallet_address") + encryptedPrivateKey String? @map("encrypted_private_key") + + // Stats + totalEarned Int @default(0) @map("total_earned") + totalSpent Int @default(0) @map("total_spent") + watchTimeMinutes Int @default(0) @map("watch_time_minutes") + + // Relations + transactions Transaction[] + watchEvents WatchEvent[] + contentRequests ContentRequest[] + sessions Session[] + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([plexId]) + @@index([walletAddress]) + @@map("users") +} + +model Transaction { + id String @id @default(uuid()) + userId String @map("user_id") + user User @relation(fields: [userId], references: [id]) + + type TransactionType + amount Int + + // For earned transactions + watchEventId String? @map("watch_event_id") + watchEvent WatchEvent? @relation(fields: [watchEventId], references: [id]) + + // For spent transactions + requestId String? @map("request_id") + request ContentRequest? @relation(fields: [requestId], references: [id]) + + // Solana transaction reference + solanaSignature String? @map("solana_signature") + + // Metadata + description String? + contentTitle String? @map("content_title") + + createdAt DateTime @default(now()) @map("created_at") + + @@index([userId]) + @@index([type]) + @@index([createdAt]) + @@map("transactions") +} + +model WatchEvent { + id String @id @default(uuid()) + userId String @map("user_id") + user User @relation(fields: [userId], references: [id]) + + // Tautulli data + sessionId String @map("session_id") + ratingKey String @map("rating_key") + contentType String @map("content_type") // movie, episode + title String + grandparentTitle String? @map("grandparent_title") // Show name for episodes + + // Watch stats + duration Int // seconds watched + percentComplete Int @map("percent_complete") + + // Credits earned + creditsEarned Int @map("credits_earned") + isProcessed Boolean @default(false) @map("is_processed") + + // Relations + transactions Transaction[] + + watchedAt DateTime @map("watched_at") + createdAt DateTime @default(now()) @map("created_at") + + @@unique([sessionId]) + @@index([userId]) + @@index([watchedAt]) + @@map("watch_events") +} + +model ContentRequest { + id String @id @default(uuid()) + userId String @map("user_id") + user User @relation(fields: [userId], references: [id]) + + // Overseer data + overseerRequestId Int @map("overseer_request_id") + + // Content info + mediaType String @map("media_type") // movie, tv + tmdbId Int @map("tmdb_id") + title String + + // Cost + creditsCost Int @map("credits_cost") + + // Status + status RequestStatus @default(PENDING) + + // Relations + transaction Transaction? + + requestedAt DateTime @map("requested_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([userId]) + @@index([status]) + @@map("content_requests") +} + +model Session { + id String @id @default(uuid()) + userId String @map("user_id") + user User @relation(fields: [userId], references: [id]) + + token String @unique + expiresAt DateTime @map("expires_at") + + createdAt DateTime @default(now()) @map("created_at") + + @@index([userId]) + @@index([token]) + @@map("sessions") +} + +model SystemSettings { + id String @id @default(cuid()) + + // Minting settings + creditsPerMinute Int @default(10) @map("credits_per_minute") + minWatchPercent Int @default(80) @map("min_watch_percent") + minWatchMinutes Int @default(5) @map("min_watch_minutes") + + // Spending settings + movieRequestCost Int @default(100) @map("movie_request_cost") + tvRequestCost Int @default(200) @map("tv_request_cost") + tvPerSeasonCost Int @default(50) @map("tv_per_season_cost") + + // Multipliers + newReleaseMultiplier Decimal @default(1.5) @map("new_release_multiplier") @db.Decimal(3, 2) + bonusMultiplierActive Boolean @default(false) @map("bonus_multiplier_active") + bonusMultiplier Decimal @default(2.0) @map("bonus_multiplier") @db.Decimal(3, 2) + + // System state + mintingPaused Boolean @default(false) @map("minting_paused") + + updatedAt DateTime @updatedAt @map("updated_at") + updatedBy String? @map("updated_by") + + @@map("system_settings") +} + +enum TransactionType { + EARN + SPEND + BONUS + ADJUSTMENT +} + +enum RequestStatus { + PENDING + APPROVED + DECLINED + COMPLETED +} diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..df06c9e --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,126 @@ +import express from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import morgan from 'morgan'; +import rateLimit from 'express-rate-limit'; +import { createServer } from 'http'; +import { Server } from 'socket.io'; +import dotenv from 'dotenv'; + +dotenv.config(); + +import { prisma } from './utils/prisma'; +import { errorHandler } from './middleware/errorHandler'; +import { authRouter } from './routes/auth'; +import { userRouter } from './routes/users'; +import { walletRouter } from './routes/wallet'; +import { transactionsRouter } from './routes/transactions'; +import { adminRouter } from './routes/admin'; +import { tautulliRouter } from './routes/tautulli'; +import { overseerRouter } from './routes/overseer'; +import { webhookRouter } from './routes/webhooks'; +import { setupSocketHandlers } from './services/socket'; + +const app = express(); +const httpServer = createServer(app); +const io = new Server(httpServer, { + cors: { + origin: process.env.FRONTEND_URL || 'http://localhost:3000', + credentials: true + } +}); + +// Security middleware +app.use(helmet()); +app.use(cors({ + origin: process.env.FRONTEND_URL || 'http://localhost:3000', + credentials: true +})); + +// Rate limiting +const limiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100 // limit each IP to 100 requests per windowMs +}); +app.use(limiter); + +// Body parsing +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true })); + +// Logging +app.use(morgan('combined')); + +// Health check +app.get('/health', (_req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +// API routes +app.use('/api/auth', authRouter); +app.use('/api/users', userRouter); +app.use('/api/wallet', walletRouter); +app.use('/api/transactions', transactionsRouter); +app.use('/api/admin', adminRouter); +app.use('/api/tautulli', tautulliRouter); +app.use('/api/overseer', overseerRouter); +app.use('/webhooks', webhookRouter); + +// Error handling +app.use(errorHandler); + +// Setup WebSocket handlers +setupSocketHandlers(io); + +// Start server +const PORT = process.env.PORT || 3001; + +async function startServer() { + try { + // Connect to database + await prisma.$connect(); + console.log('✅ Connected to database'); + + // Ensure default settings exist + const settings = await prisma.systemSettings.findFirst(); + if (!settings) { + await prisma.systemSettings.create({ + data: { + id: 'default' + } + }); + console.log('✅ Created default system settings'); + } + + httpServer.listen(PORT, () => { + console.log(`🚀 Server running on port ${PORT}`); + console.log(`📊 API URL: ${process.env.API_URL || `http://localhost:${PORT}`}`); + }); + } catch (error) { + console.error('Failed to start server:', error); + process.exit(1); + } +} + +// Graceful shutdown +process.on('SIGTERM', async () => { + console.log('SIGTERM received, shutting down gracefully'); + await prisma.$disconnect(); + httpServer.close(() => { + console.log('Server closed'); + process.exit(0); + }); +}); + +process.on('SIGINT', async () => { + console.log('SIGINT received, shutting down gracefully'); + await prisma.$disconnect(); + httpServer.close(() => { + console.log('Server closed'); + process.exit(0); + }); +}); + +startServer(); + +export { io }; diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..c9ee7c0 --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -0,0 +1,61 @@ +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import { prisma } from '../utils/prisma'; + +const JWT_SECRET = process.env.JWT_SECRET || 'secret'; + +export interface AuthenticatedRequest extends Request { + user?: { + id: string; + plexId: string; + isAdmin: boolean; + }; +} + +export const authenticate = async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +) => { + const authHeader = req.headers.authorization; + + if (!authHeader?.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const token = authHeader.substring(7); + + try { + const decoded = jwt.verify(token, JWT_SECRET) as any; + + const user = await prisma.user.findUnique({ + where: { id: decoded.userId }, + select: { id: true, plexId: true, isAdmin: true, isActive: true } + }); + + if (!user || !user.isActive) { + return res.status(401).json({ error: 'User not found or inactive' }); + } + + req.user = { + id: user.id, + plexId: user.plexId, + isAdmin: user.isAdmin + }; + + next(); + } catch (error) { + return res.status(401).json({ error: 'Invalid or expired token' }); + } +}; + +export const requireAdmin = ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +) => { + if (!req.user?.isAdmin) { + return res.status(403).json({ error: 'Admin access required' }); + } + next(); +}; diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts new file mode 100644 index 0000000..d780371 --- /dev/null +++ b/backend/src/middleware/errorHandler.ts @@ -0,0 +1,32 @@ +import { Request, Response, NextFunction } from 'express'; + +export interface ApiError extends Error { + statusCode?: number; + code?: string; +} + +export const errorHandler = ( + err: ApiError, + _req: Request, + res: Response, + _next: NextFunction +) => { + console.error('Error:', err); + + const statusCode = err.statusCode || 500; + const message = err.message || 'Internal Server Error'; + + res.status(statusCode).json({ + error: { + message, + code: err.code || 'INTERNAL_ERROR', + ...(process.env.NODE_ENV === 'development' && { stack: err.stack }) + } + }); +}; + +export const asyncHandler = (fn: Function) => { + return (req: Request, res: Response, next: NextFunction) => { + Promise.resolve(fn(req, res, next)).catch(next); + }; +}; diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts new file mode 100644 index 0000000..f479a12 --- /dev/null +++ b/backend/src/routes/admin.ts @@ -0,0 +1,308 @@ +import { Router } from 'express'; +import { authenticate, AuthenticatedRequest, requireAdmin } from '../middleware/auth'; +import { prisma } from '../utils/prisma'; +import { asyncHandler } from '../middleware/errorHandler'; +import { mintTokens } from '../services/solana'; + +const router = Router(); + +// Get system settings +router.get('/settings', + authenticate, + requireAdmin, + asyncHandler(async (_req: AuthenticatedRequest, res) => { + const settings = await prisma.systemSettings.findFirst(); + + if (!settings) { + return res.status(404).json({ error: 'Settings not found' }); + } + + res.json(settings); + }) +); + +// Update system settings +router.put('/settings', + authenticate, + requireAdmin, + asyncHandler(async (req: AuthenticatedRequest, res) => { + const { + creditsPerMinute, + minWatchPercent, + minWatchMinutes, + movieRequestCost, + tvRequestCost, + newReleaseMultiplier, + bonusMultiplierActive, + bonusMultiplier + } = req.body; + + const settings = await prisma.systemSettings.update({ + where: { id: 'default' }, + data: { + creditsPerMinute, + minWatchPercent, + minWatchMinutes, + movieRequestCost, + tvRequestCost, + newReleaseMultiplier, + bonusMultiplierActive, + bonusMultiplier, + updatedBy: req.user!.id + } + }); + + res.json(settings); + }) +); + +// Pause minting +router.post('/pause', + authenticate, + requireAdmin, + asyncHandler(async (req: AuthenticatedRequest, res) => { + await prisma.systemSettings.update({ + where: { id: 'default' }, + data: { + mintingPaused: true, + updatedBy: req.user!.id + } + }); + + res.json({ message: 'Minting paused' }); + }) +); + +// Resume minting +router.post('/resume', + authenticate, + requireAdmin, + asyncHandler(async (req: AuthenticatedRequest, res) => { + await prisma.systemSettings.update({ + where: { id: 'default' }, + data: { + mintingPaused: false, + updatedBy: req.user!.id + } + }); + + res.json({ message: 'Minting resumed' }); + }) +); + +// Get all users +router.get('/users', + authenticate, + requireAdmin, + asyncHandler(async (req: AuthenticatedRequest, res) => { + const { page = '1', limit = '50', search } = req.query; + + const pageNum = parseInt(page as string); + const limitNum = Math.min(parseInt(limit as string), 100); + const skip = (pageNum - 1) * limitNum; + + const where: any = {}; + if (search) { + where.OR = [ + { plexUsername: { contains: search as string, mode: 'insensitive' } }, + { email: { contains: search as string, mode: 'insensitive' } } + ]; + } + + const [users, total] = await Promise.all([ + prisma.user.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: limitNum, + select: { + id: true, + plexId: true, + plexUsername: true, + email: true, + isAdmin: true, + isActive: true, + walletAddress: true, + totalEarned: true, + totalSpent: true, + watchTimeMinutes: true, + createdAt: true + } + }), + prisma.user.count({ where }) + ]); + + res.json({ + users, + pagination: { + page: pageNum, + limit: limitNum, + total, + totalPages: Math.ceil(total / limitNum) + } + }); + }) +); + +// Update user +router.put('/users/:id', + authenticate, + requireAdmin, + asyncHandler(async (req: AuthenticatedRequest, res) => { + const { isAdmin, isActive } = req.body; + + const user = await prisma.user.update({ + where: { id: req.params.id }, + data: { + isAdmin, + isActive + }, + select: { + id: true, + plexUsername: true, + isAdmin: true, + isActive: true + } + }); + + res.json(user); + }) +); + +// Grant bonus credits +router.post('/users/:id/bonus', + authenticate, + requireAdmin, + asyncHandler(async (req: AuthenticatedRequest, res) => { + const { amount, reason } = req.body; + + if (!amount || amount <= 0) { + return res.status(400).json({ error: 'Valid amount required' }); + } + + const user = await prisma.user.findUnique({ + where: { id: req.params.id } + }); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + if (!user.walletAddress) { + return res.status(400).json({ error: 'User has no wallet' }); + } + + // Mint bonus tokens + const signature = await mintTokens( + user.walletAddress, + amount, + { + sessionId: `BONUS-${Date.now()}`, + contentTitle: reason || 'Admin Bonus', + watchDurationMinutes: 0 + } + ); + + if (!signature) { + return res.status(500).json({ error: 'Failed to mint bonus' }); + } + + // Create transaction record + const transaction = await prisma.transaction.create({ + data: { + userId: user.id, + type: 'BONUS', + amount, + solanaSignature: signature, + description: reason || 'Admin bonus', + contentTitle: reason || 'Admin Bonus' + } + }); + + // Update user stats + await prisma.user.update({ + where: { id: user.id }, + data: { + totalEarned: { increment: amount } + } + }); + + res.json({ + success: true, + amount, + solanaSignature: signature, + transaction + }); + }) +); + +// Get dashboard analytics +router.get('/analytics', + authenticate, + requireAdmin, + asyncHandler(async (_req: AuthenticatedRequest, res) => { + const sevenDaysAgo = new Date(); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const [ + userStats, + transactionStats, + watchStats, + dailyActivity + ] = await Promise.all([ + // User stats + prisma.$queryRaw` + SELECT + COUNT(*) as total, + COUNT(CASE WHEN wallet_address IS NOT NULL THEN 1 END) as with_wallet, + COUNT(CASE WHEN created_at >= ${sevenDaysAgo} THEN 1 END) as new_this_week + FROM users + `, + + // Transaction stats + prisma.$queryRaw` + SELECT + SUM(CASE WHEN type = 'EARN' THEN amount ELSE 0 END) as total_earned, + SUM(CASE WHEN type = 'SPEND' THEN amount ELSE 0 END) as total_spent, + COUNT(*) as total_transactions + FROM transactions + `, + + // Watch stats + prisma.$queryRaw` + SELECT + SUM(duration) as total_seconds, + SUM(credits_earned) as total_credits, + COUNT(*) as total_events + FROM watch_events + WHERE is_processed = true + `, + + // Daily activity (last 30 days) + prisma.$queryRaw` + SELECT + DATE(created_at) as date, + COUNT(DISTINCT user_id) as active_users, + SUM(CASE WHEN type = 'EARN' THEN amount ELSE 0 END) as earned, + SUM(CASE WHEN type = 'SPEND' THEN amount ELSE 0 END) as spent + FROM transactions + WHERE created_at >= ${thirtyDaysAgo} + GROUP BY DATE(created_at) + ORDER BY date DESC + LIMIT 30 + ` + ]); + + res.json({ + users: userStats[0], + transactions: transactionStats[0], + watchStats: watchStats[0], + dailyActivity + }); + }) +); + +export { router as adminRouter }; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..a046f74 --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,186 @@ +import { Router } from 'express'; +import axios from 'axios'; +import jwt from 'jsonwebtoken'; +import { prisma } from '../utils/prisma'; +import { asyncHandler } from '../middleware/errorHandler'; + +const router = Router(); + +// Plex OAuth configuration +const PLEX_CLIENT_ID = process.env.PLEX_CLIENT_ID || ''; +const PLEX_REDIRECT_URI = process.env.PLEX_REDIRECT_URI || ''; +const JWT_SECRET = process.env.JWT_SECRET || 'secret'; + +// Step 1: Get Plex OAuth URL +router.get('/plex/url', asyncHandler(async (_req, res) => { + const params = new URLSearchParams({ + client_id: PLEX_CLIENT_ID, + redirect_uri: PLEX_REDIRECT_URI, + response_type: 'code', + scope: 'openid profile' + }); + + const authUrl = `https://app.plex.tv/auth#?${params.toString()}`; + + res.json({ authUrl }); +})); + +// Step 2: Handle Plex OAuth callback +router.post('/plex/callback', asyncHandler(async (req, res) => { + const { code } = req.body; + + if (!code) { + return res.status(400).json({ error: 'Authorization code required' }); + } + + // Exchange code for Plex token + const tokenResponse = await axios.post( + 'https://plex.tv/api/v2/oauth/token', + { + code, + client_id: PLEX_CLIENT_ID, + grant_type: 'authorization_code' + }, + { + headers: { + 'Accept': 'application/json', + 'X-Plex-Client-Identifier': PLEX_CLIENT_ID + } + } + ); + + const plexToken = tokenResponse.data.access_token; + + // Get user info from Plex + const userResponse = await axios.get('https://plex.tv/api/v2/user', { + headers: { + 'X-Plex-Token': plexToken, + 'X-Plex-Client-Identifier': PLEX_CLIENT_ID, + 'Accept': 'application/json' + } + }); + + const plexUser = userResponse.data; + + // Check if user exists, create if not + let user = await prisma.user.findUnique({ + where: { plexId: plexUser.id } + }); + + if (!user) { + user = await prisma.user.create({ + data: { + plexId: plexUser.id, + plexUsername: plexUser.username || plexUser.email, + email: plexUser.email, + // Check if user is Plex admin (implement your logic) + isAdmin: false + } + }); + } else { + // Update user info + user = await prisma.user.update({ + where: { id: user.id }, + data: { + plexUsername: plexUser.username || plexUser.email, + email: plexUser.email + } + }); + } + + // Create session + const session = await prisma.session.create({ + data: { + userId: user.id, + token: jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' }), + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) + } + }); + + // Generate JWT + const token = jwt.sign( + { + userId: user.id, + plexId: user.plexId, + isAdmin: user.isAdmin + }, + JWT_SECRET, + { expiresIn: '7d' } + ); + + res.json({ + token, + sessionToken: session.token, + user: { + id: user.id, + plexId: user.plexId, + plexUsername: user.plexUsername, + email: user.email, + isAdmin: user.isAdmin, + walletAddress: user.walletAddress, + totalEarned: user.totalEarned, + totalSpent: user.totalSpent + } + }); +})); + +// Verify token +router.get('/verify', asyncHandler(async (req, res) => { + const authHeader = req.headers.authorization; + + if (!authHeader?.startsWith('Bearer ')) { + return res.status(401).json({ error: 'No token provided' }); + } + + const token = authHeader.substring(7); + + try { + const decoded = jwt.verify(token, JWT_SECRET) as any; + + const user = await prisma.user.findUnique({ + where: { id: decoded.userId } + }); + + if (!user || !user.isActive) { + return res.status(401).json({ error: 'User not found or inactive' }); + } + + res.json({ + user: { + id: user.id, + plexId: user.plexId, + plexUsername: user.plexUsername, + email: user.email, + isAdmin: user.isAdmin, + walletAddress: user.walletAddress, + totalEarned: user.totalEarned, + totalSpent: user.totalSpent + } + }); + } catch (error) { + res.status(401).json({ error: 'Invalid token' }); + } +})); + +// Logout +router.post('/logout', asyncHandler(async (req, res) => { + const authHeader = req.headers.authorization; + + if (authHeader?.startsWith('Bearer ')) { + const token = authHeader.substring(7); + + try { + const decoded = jwt.verify(token, JWT_SECRET) as any; + + await prisma.session.deleteMany({ + where: { userId: decoded.userId } + }); + } catch { + // Invalid token, ignore + } + } + + res.json({ message: 'Logged out successfully' }); +})); + +export { router as authRouter }; diff --git a/backend/src/routes/overseer.ts b/backend/src/routes/overseer.ts new file mode 100644 index 0000000..4b1ae1d --- /dev/null +++ b/backend/src/routes/overseer.ts @@ -0,0 +1,217 @@ +import { Router } from 'express'; +import axios from 'axios'; +import { authenticate, AuthenticatedRequest } from '../middleware/auth'; +import { prisma } from '../utils/prisma'; +import { asyncHandler } from '../middleware/errorHandler'; +import { getTokenBalance } from '../services/solana'; +import { io } from '../index'; + +const router = Router(); + +const OVERSEER_URL = process.env.OVERSEER_URL || ''; +const OVERSEER_API_KEY = process.env.OVERSEER_API_KEY || ''; + +const overseerClient = axios.create({ + baseURL: `${OVERSEER_URL}/api/v1`, + headers: { + 'X-Api-Key': OVERSEER_API_KEY + } +}); + +// Get request costs +router.get('/costs', + authenticate, + asyncHandler(async (_req: AuthenticatedRequest, res) => { + const settings = await prisma.systemSettings.findFirst(); + + res.json({ + movie: settings?.movieRequestCost || 100, + tv: settings?.tvRequestCost || 200, + tvPerSeason: settings?.tvPerSeasonCost || 50 + }); + }) +); + +// Get user's balance and request availability +router.get('/balance', + authenticate, + asyncHandler(async (req: AuthenticatedRequest, res) => { + const user = await prisma.user.findUnique({ + where: { id: req.user!.id } + }); + + if (!user?.walletAddress) { + return res.json({ + hasWallet: false, + balance: 0, + canRequest: false + }); + } + + const balance = await getTokenBalance(user.walletAddress); + const settings = await prisma.systemSettings.findFirst(); + + res.json({ + hasWallet: true, + balance, + canRequestMovie: balance >= (settings?.movieRequestCost || 100), + canRequestTV: balance >= (settings?.tvRequestCost || 200), + costs: { + movie: settings?.movieRequestCost || 100, + tv: settings?.tvRequestCost || 200 + } + }); + }) +); + +// Search for content +router.get('/search', + authenticate, + asyncHandler(async (req: AuthenticatedRequest, res) => { + const { query } = req.query; + + if (!query) { + return res.status(400).json({ error: 'Query required' }); + } + + const response = await overseerClient.get('/search', { + params: { query } + }); + + res.json(response.data); + }) +); + +// Request content +router.post('/request', + authenticate, + asyncHandler(async (req: AuthenticatedRequest, res) => { + const { mediaType, mediaId, title, seasons } = req.body; + + if (!mediaType || !mediaId || !title) { + return res.status(400).json({ error: 'Missing required fields' }); + } + + const user = await prisma.user.findUnique({ + where: { id: req.user!.id } + }); + + if (!user?.walletAddress) { + return res.status(400).json({ error: 'Wallet required' }); + } + + const settings = await prisma.systemSettings.findFirst(); + + // Calculate cost + let cost = 0; + if (mediaType === 'movie') { + cost = settings?.movieRequestCost || 100; + } else if (mediaType === 'tv') { + cost = settings?.tvRequestCost || 200; + if (seasons && seasons.length > 1) { + cost += (seasons.length - 1) * (settings?.tvPerSeasonCost || 50); + } + } + + // Check balance + const balance = await getTokenBalance(user.walletAddress); + if (balance < cost) { + return res.status(400).json({ + error: 'Insufficient balance', + required: cost, + current: balance + }); + } + + // Create request in Overseer + const overseerRequest = await overseerClient.post('/request', { + mediaType, + mediaId, + ...(seasons && { seasons }) + }); + + // Create local request record + const request = await prisma.contentRequest.create({ + data: { + userId: user.id, + overseerRequestId: overseerRequest.data.id, + mediaType, + tmdbId: mediaId, + title, + creditsCost: cost, + status: 'PENDING', + requestedAt: new Date() + } + }); + + res.json({ + success: true, + request, + cost, + message: 'Request submitted. Credits will be deducted when approved.' + }); + }) +); + +// Webhook: Handle Overseer request status changes +router.post('/webhook', + asyncHandler(async (req, res) => { + const { request_id, status } = req.body; + + if (!request_id || !status) { + return res.status(400).json({ error: 'Missing fields' }); + } + + // Find local request + const request = await prisma.contentRequest.findFirst({ + where: { overseerRequestId: request_id }, + include: { user: true } + }); + + if (!request) { + return res.status(404).json({ error: 'Request not found' }); + } + + // Update status + const updatedRequest = await prisma.contentRequest.update({ + where: { id: request.id }, + data: { status } + }); + + // If approved, deduct credits + if (status === 'APPROVED' && !request.user.totalSpent) { + // Note: Actual burning would happen here + // For now, we just record the transaction + + const transaction = await prisma.transaction.create({ + data: { + userId: request.userId, + type: 'SPEND', + amount: request.creditsCost, + requestId: request.id, + description: `Request: ${request.title}`, + contentTitle: request.title + } + }); + + // Update user stats + await prisma.user.update({ + where: { id: request.userId }, + data: { + totalSpent: { increment: request.creditsCost } + } + }); + + // Emit update + io.to(`user:${request.userId}`).emit('credits_spent', { + amount: request.creditsCost, + title: request.title, + transaction + }); + } + + res.json({ success: true, request: updatedRequest }); + }) +); + +export { router as overseerRouter }; diff --git a/backend/src/routes/tautulli.ts b/backend/src/routes/tautulli.ts new file mode 100644 index 0000000..dd79860 --- /dev/null +++ b/backend/src/routes/tautulli.ts @@ -0,0 +1,93 @@ +import { Router } from 'express'; +import axios from 'axios'; +import { authenticate, AuthenticatedRequest, requireAdmin } from '../middleware/auth'; +import { asyncHandler } from '../middleware/errorHandler'; + +const router = Router(); + +const TAUTULLI_URL = process.env.TAUTULLI_URL || ''; +const TAUTULLI_API_KEY = process.env.TAUTULLI_API_KEY || ''; + +// Get Tautulli connection status +router.get('/status', + authenticate, + requireAdmin, + asyncHandler(async (_req: AuthenticatedRequest, res) => { + try { + const response = await axios.get(`${TAUTULLI_URL}/api/v2`, { + params: { + apikey: TAUTULLI_API_KEY, + cmd: 'get_server_info' + } + }); + + res.json({ + connected: true, + data: response.data.response.data + }); + } catch (error) { + res.status(500).json({ + connected: false, + error: 'Failed to connect to Tautulli' + }); + } + }) +); + +// Get watch statistics +router.get('/stats', + authenticate, + requireAdmin, + asyncHandler(async (_req: AuthenticatedRequest, res) => { + try { + const response = await axios.get(`${TAUTULLI_URL}/api/v2`, { + params: { + apikey: TAUTULLI_API_KEY, + cmd: 'get_libraries' + } + }); + + res.json(response.data.response.data); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch stats' }); + } + }) +); + +// Get webhook configuration guide +router.get('/webhook-config', + authenticate, + requireAdmin, + asyncHandler(async (_req: AuthenticatedRequest, res) => { + const webhookUrl = `${process.env.API_URL}/webhooks/tautulli`; + + res.json({ + webhookUrl, + instructions: [ + '1. Open Tautulli Settings', + '2. Go to Notification Agents', + '3. Add Webhook', + '4. Set Webhook URL to the URL above', + '5. Set Webhook Method to POST', + '6. Configure triggers for "Watched" events', + '7. Set payload to JSON format' + ], + payloadTemplate: { + action: 'watched', + user_id: '{user_id}', + username: '{username}', + rating_key: '{rating_key}', + session_key: '{session_key}', + media_type: '{media_type}', + title: '{title}', + grandparent_title: '{grandparent_title}', + started: '{started}', + stopped: '{stopped}', + percent_complete: '{percent_complete}', + is_new: '{is_new}' + } + }); + }) +); + +export { router as tautulliRouter }; diff --git a/backend/src/routes/transactions.ts b/backend/src/routes/transactions.ts new file mode 100644 index 0000000..db77766 --- /dev/null +++ b/backend/src/routes/transactions.ts @@ -0,0 +1,210 @@ +import { Router } from 'express'; +import { authenticate, AuthenticatedRequest, requireAdmin } from '../middleware/auth'; +import { prisma } from '../utils/prisma'; +import { asyncHandler } from '../middleware/errorHandler'; + +const router = Router(); + +// Get user's transactions +router.get('/', + authenticate, + asyncHandler(async (req: AuthenticatedRequest, res) => { + const { page = '1', limit = '20', type } = req.query; + + const pageNum = parseInt(page as string); + const limitNum = Math.min(parseInt(limit as string), 100); + const skip = (pageNum - 1) * limitNum; + + const where: any = { userId: req.user!.id }; + if (type) { + where.type = type; + } + + const [transactions, total] = await Promise.all([ + prisma.transaction.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: limitNum, + include: { + watchEvent: { + select: { + duration: true, + percentComplete: true + } + }, + request: { + select: { + mediaType: true, + status: true + } + } + } + }), + prisma.transaction.count({ where }) + ]); + + res.json({ + transactions, + pagination: { + page: pageNum, + limit: limitNum, + total, + totalPages: Math.ceil(total / limitNum) + } + }); + }) +); + +// Get transaction stats +router.get('/stats', + authenticate, + asyncHandler(async (req: AuthenticatedRequest, res) => { + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const [ + totalStats, + recentStats, + byType + ] = await Promise.all([ + // All time stats + prisma.transaction.groupBy({ + by: ['type'], + where: { userId: req.user!.id }, + _sum: { amount: true }, + _count: { id: true } + }), + // Last 30 days + prisma.transaction.groupBy({ + by: ['type'], + where: { + userId: req.user!.id, + createdAt: { gte: thirtyDaysAgo } + }, + _sum: { amount: true }, + _count: { id: true } + }), + // By type breakdown + prisma.transaction.findMany({ + where: { userId: req.user!.id }, + select: { + type: true, + amount: true, + createdAt: true + }, + orderBy: { createdAt: 'desc' }, + take: 100 + }) + ]); + + // Calculate daily earnings for chart + const dailyEarnings = await prisma.$queryRaw` + SELECT + DATE(created_at) as date, + SUM(CASE WHEN type = 'EARN' THEN amount ELSE 0 END) as earned, + SUM(CASE WHEN type = 'SPEND' THEN amount ELSE 0 END) as spent + FROM transactions + WHERE user_id = ${req.user!.id} + AND created_at >= ${thirtyDaysAgo} + GROUP BY DATE(created_at) + ORDER BY date DESC + `; + + res.json({ + total: totalStats, + recent: recentStats, + dailyEarnings, + recentTransactions: byType.slice(0, 10) + }); + }) +); + +// Admin: Get all transactions +router.get('/admin/all', + authenticate, + requireAdmin, + asyncHandler(async (req: AuthenticatedRequest, res) => { + const { page = '1', limit = '50', userId, type } = req.query; + + const pageNum = parseInt(page as string); + const limitNum = Math.min(parseInt(limit as string), 100); + const skip = (pageNum - 1) * limitNum; + + const where: any = {}; + if (userId) where.userId = userId; + if (type) where.type = type; + + const [transactions, total] = await Promise.all([ + prisma.transaction.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: limitNum, + include: { + user: { + select: { + plexUsername: true, + walletAddress: true + } + } + } + }), + prisma.transaction.count({ where }) + ]); + + res.json({ + transactions, + pagination: { + page: pageNum, + limit: limitNum, + total, + totalPages: Math.ceil(total / limitNum) + } + }); + }) +); + +// Admin: Get system-wide stats +router.get('/admin/stats', + authenticate, + requireAdmin, + asyncHandler(async (_req: AuthenticatedRequest, res) => { + const [ + totalMinted, + totalBurned, + totalUsers, + activeUsers, + recentTransactions + ] = await Promise.all([ + prisma.transaction.aggregate({ + where: { type: 'EARN' }, + _sum: { amount: true } + }), + prisma.transaction.aggregate({ + where: { type: 'SPEND' }, + _sum: { amount: true } + }), + prisma.user.count(), + prisma.user.count({ where: { walletAddress: { not: null } } }), + prisma.transaction.count({ + where: { + createdAt: { + gte: new Date(Date.now() - 24 * 60 * 60 * 1000) + } + } + }) + ]); + + res.json({ + totalMinted: totalMinted._sum.amount || 0, + totalBurned: totalBurned._sum.amount || 0, + netSupply: (totalMinted._sum.amount || 0) - (totalBurned._sum.amount || 0), + totalUsers, + activeUsers, + recentTransactions + }); + }) +); + +export { router as transactionsRouter }; diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts new file mode 100644 index 0000000..e849beb --- /dev/null +++ b/backend/src/routes/users.ts @@ -0,0 +1,135 @@ +import { Router } from 'express'; +import { authenticate, AuthenticatedRequest } from '../middleware/auth'; +import { prisma } from '../utils/prisma'; +import { asyncHandler } from '../middleware/errorHandler'; + +const router = Router(); + +// Get current user profile +router.get('/me', + authenticate, + asyncHandler(async (req: AuthenticatedRequest, res) => { + const user = await prisma.user.findUnique({ + where: { id: req.user!.id }, + select: { + id: true, + plexId: true, + plexUsername: true, + email: true, + isAdmin: true, + walletAddress: true, + totalEarned: true, + totalSpent: true, + watchTimeMinutes: true, + createdAt: true + } + }); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + res.json(user); + }) +); + +// Update user profile +router.put('/me', + authenticate, + asyncHandler(async (req: AuthenticatedRequest, res) => { + const { email } = req.body; + + const user = await prisma.user.update({ + where: { id: req.user!.id }, + data: { email }, + select: { + id: true, + plexUsername: true, + email: true, + walletAddress: true + } + }); + + res.json(user); + }) +); + +// Get user's watch history +router.get('/me/watch-history', + authenticate, + asyncHandler(async (req: AuthenticatedRequest, res) => { + const { page = '1', limit = '20' } = req.query; + + const pageNum = parseInt(page as string); + const limitNum = Math.min(parseInt(limit as string), 50); + const skip = (pageNum - 1) * limitNum; + + const [events, total] = await Promise.all([ + prisma.watchEvent.findMany({ + where: { userId: req.user!.id }, + orderBy: { watchedAt: 'desc' }, + skip, + take: limitNum, + select: { + id: true, + contentType: true, + title: true, + grandparentTitle: true, + duration: true, + percentComplete: true, + creditsEarned: true, + isProcessed: true, + watchedAt: true + } + }), + prisma.watchEvent.count({ where: { userId: req.user!.id } }) + ]); + + res.json({ + events, + pagination: { + page: pageNum, + limit: limitNum, + total, + totalPages: Math.ceil(total / limitNum) + } + }); + }) +); + +// Get user's content requests +router.get('/me/requests', + authenticate, + asyncHandler(async (req: AuthenticatedRequest, res) => { + const { page = '1', limit = '20', status } = req.query; + + const pageNum = parseInt(page as string); + const limitNum = Math.min(parseInt(limit as string), 50); + const skip = (pageNum - 1) * limitNum; + + const where: any = { userId: req.user!.id }; + if (status) where.status = status; + + const [requests, total] = await Promise.all([ + prisma.contentRequest.findMany({ + where, + orderBy: { requestedAt: 'desc' }, + skip, + take: limitNum + }), + prisma.contentRequest.count({ where }) + ]); + + res.json({ + requests, + pagination: { + page: pageNum, + limit: limitNum, + total, + totalPages: Math.ceil(total / limitNum) + } + }); + }) +); + +export { router as userRouter }; diff --git a/backend/src/routes/wallet.ts b/backend/src/routes/wallet.ts new file mode 100644 index 0000000..d3377aa --- /dev/null +++ b/backend/src/routes/wallet.ts @@ -0,0 +1,193 @@ +import { Router } from 'express'; +import { authenticate, AuthenticatedRequest, requireAdmin } from '../middleware/auth'; +import { prisma } from '../utils/prisma'; +import { createWallet, getTokenBalance, requestAirdrop } from '../services/solana'; +import { asyncHandler } from '../middleware/errorHandler'; +import crypto from 'crypto'; + +const router = Router(); +const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'default-key-32-chars-long!!!!!'; + +// Encrypt private key +function encrypt(text: string): string { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-gcm', Buffer.from(ENCRYPTION_KEY), iv); + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + const authTag = cipher.getAuthTag(); + return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted; +} + +// Decrypt private key +function decrypt(encryptedData: string): string { + const parts = encryptedData.split(':'); + const iv = Buffer.from(parts[0], 'hex'); + const authTag = Buffer.from(parts[1], 'hex'); + const encrypted = parts[2]; + const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(ENCRYPTION_KEY), iv); + decipher.setAuthTag(authTag); + let decrypted = decipher.update(encrypted, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; +} + +// Get user's wallet info +router.get('/', + authenticate, + asyncHandler(async (req: AuthenticatedRequest, res) => { + const user = await prisma.user.findUnique({ + where: { id: req.user!.id }, + select: { + walletAddress: true, + totalEarned: true, + totalSpent: true + } + }); + + if (!user?.walletAddress) { + return res.json({ + hasWallet: false, + balance: 0, + totalEarned: user?.totalEarned || 0, + totalSpent: user?.totalSpent || 0 + }); + } + + // Get on-chain balance + const balance = await getTokenBalance(user.walletAddress); + + res.json({ + hasWallet: true, + address: user.walletAddress, + balance, + totalEarned: user.totalEarned, + totalSpent: user.totalSpent, + explorerUrl: `https://explorer.solana.com/address/${user.walletAddress}?cluster=devnet` + }); + }) +); + +// Create new wallet +router.post('/create', + authenticate, + asyncHandler(async (req: AuthenticatedRequest, res) => { + const user = await prisma.user.findUnique({ + where: { id: req.user!.id } + }); + + if (user?.walletAddress) { + return res.status(400).json({ error: 'Wallet already exists' }); + } + + // Create new Solana wallet + const wallet = createWallet(); + const encryptedKey = encrypt(wallet.secretKey); + + await prisma.user.update({ + where: { id: req.user!.id }, + data: { + walletAddress: wallet.publicKey, + encryptedPrivateKey: encryptedKey + } + }); + + // Request airdrop for testing + await requestAirdrop(wallet.publicKey); + + res.json({ + address: wallet.publicKey, + message: 'Wallet created successfully. Funded with 2 SOL for transaction fees.', + warning: 'Please backup your recovery phrase if shown. This is the only time it will be displayed.' + }); + }) +); + +// Connect existing wallet +router.post('/connect', + authenticate, + asyncHandler(async (req: AuthenticatedRequest, res) => { + const { address } = req.body; + + if (!address) { + return res.status(400).json({ error: 'Wallet address required' }); + } + + // Check if address is already connected to another user + const existing = await prisma.user.findFirst({ + where: { + walletAddress: address, + NOT: { id: req.user!.id } + } + }); + + if (existing) { + return res.status(400).json({ error: 'Wallet already connected to another account' }); + } + + await prisma.user.update({ + where: { id: req.user!.id }, + data: { walletAddress: address } + }); + + res.json({ + address, + message: 'Wallet connected successfully' + }); + }) +); + +// Get recovery phrase (only shown once at creation) +router.post('/backup', + authenticate, + asyncHandler(async (req: AuthenticatedRequest, res) => { + const user = await prisma.user.findUnique({ + where: { id: req.user!.id } + }); + + if (!user?.encryptedPrivateKey) { + return res.status(400).json({ error: 'No wallet found' }); + } + + // Decrypt and return private key for backup + const privateKey = decrypt(user.encryptedPrivateKey); + + res.json({ + privateKey, + warning: 'Store this securely. Never share it with anyone.' + }); + }) +); + +// Admin: Get user's wallet +router.get('/admin/:userId', + authenticate, + requireAdmin, + asyncHandler(async (req: AuthenticatedRequest, res) => { + const user = await prisma.user.findUnique({ + where: { id: req.params.userId }, + select: { + id: true, + plexUsername: true, + walletAddress: true, + totalEarned: true, + totalSpent: true + } + }); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + let balance = 0; + if (user.walletAddress) { + balance = await getTokenBalance(user.walletAddress); + } + + res.json({ + ...user, + balance + }); + }) +); + +export { router as walletRouter }; diff --git a/backend/src/routes/webhooks.ts b/backend/src/routes/webhooks.ts new file mode 100644 index 0000000..4c1fbfa --- /dev/null +++ b/backend/src/routes/webhooks.ts @@ -0,0 +1,204 @@ +import { Router } from 'express'; +import { prisma } from '../utils/prisma'; +import { mintTokens } from '../services/solana'; +import { asyncHandler } from '../middleware/errorHandler'; +import { io } from '../index'; +import crypto from 'crypto'; + +const router = Router(); +const WEBHOOK_SECRET = process.env.TAUTULLI_WEBHOOK_SECRET || ''; + +// Verify webhook signature +function verifyWebhookSignature(payload: string, signature: string): boolean { + if (!WEBHOOK_SECRET) return true; // Skip verification if no secret set + + const expected = crypto + .createHmac('sha256', WEBHOOK_SECRET) + .update(payload) + .digest('hex'); + + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expected) + ); +} + +// Tautulli webhook endpoint +router.post('/tautulli', + asyncHandler(async (req, res) => { + const signature = req.headers['x-tautulli-signature'] as string; + const payload = JSON.stringify(req.body); + + // Verify signature if configured + if (WEBHOOK_SECRET && signature && !verifyWebhookSignature(payload, signature)) { + return res.status(401).json({ error: 'Invalid signature' }); + } + + const event = req.body; + + // Only process watched events + if (event.action !== 'watched') { + return res.json({ message: 'Event type not processed' }); + } + + // Validate required fields + if (!event.user_id || !event.rating_key || !event.session_key) { + return res.status(400).json({ error: 'Missing required fields' }); + } + + // Find user by Plex ID + const user = await prisma.user.findUnique({ + where: { plexId: event.user_id.toString() } + }); + + if (!user) { + console.log(`User not found for Plex ID: ${event.user_id}`); + return res.status(404).json({ error: 'User not found' }); + } + + if (!user.walletAddress) { + console.log(`User ${user.plexUsername} has no wallet`); + return res.status(400).json({ error: 'User has no wallet' }); + } + + // Check for duplicate events + const existing = await prisma.watchEvent.findUnique({ + where: { sessionId: event.session_key.toString() } + }); + + if (existing) { + return res.json({ message: 'Event already processed' }); + } + + // Get system settings + const settings = await prisma.systemSettings.findFirst(); + const creditsPerMinute = settings?.creditsPerMinute || 10; + const minWatchPercent = settings?.minWatchPercent || 80; + const minWatchMinutes = settings?.minWatchMinutes || 5; + + // Calculate watch duration + const watchDurationMinutes = Math.floor((event.stopped - event.started) / 60); + const percentComplete = event.percent_complete || 0; + + // Validate minimum requirements + if (percentComplete < minWatchPercent) { + return res.json({ + message: 'Watch percentage too low', + percentComplete, + required: minWatchPercent + }); + } + + if (watchDurationMinutes < minWatchMinutes) { + return res.json({ + message: 'Watch duration too short', + watchDurationMinutes, + required: minWatchMinutes + }); + } + + // Calculate credits + let creditsEarned = watchDurationMinutes * creditsPerMinute; + + // Apply multipliers + if (settings?.newReleaseMultiplier && event.is_new) { + creditsEarned = Math.floor(creditsEarned * Number(settings.newReleaseMultiplier)); + } + + if (settings?.bonusMultiplierActive) { + creditsEarned = Math.floor(creditsEarned * Number(settings.bonusMultiplier)); + } + + // Create watch event record + const watchEvent = await prisma.watchEvent.create({ + data: { + userId: user.id, + sessionId: event.session_key.toString(), + ratingKey: event.rating_key.toString(), + contentType: event.media_type, + title: event.title, + grandparentTitle: event.grandparent_title, + duration: event.stopped - event.started, + percentComplete: Math.floor(percentComplete), + creditsEarned, + watchedAt: new Date(event.stopped * 1000) + } + }); + + // Mint tokens on Solana + const signature = await mintTokens( + user.walletAddress, + creditsEarned, + { + sessionId: event.session_key.toString(), + contentTitle: event.title, + watchDurationMinutes + } + ); + + if (signature) { + // Create transaction record + const transaction = await prisma.transaction.create({ + data: { + userId: user.id, + type: 'EARN', + amount: creditsEarned, + watchEventId: watchEvent.id, + solanaSignature: signature, + description: `Watched ${event.title}`, + contentTitle: event.title + } + }); + + // Update user stats + await prisma.user.update({ + where: { id: user.id }, + data: { + totalEarned: { increment: creditsEarned }, + watchTimeMinutes: { increment: watchDurationMinutes } + } + }); + + // Mark watch event as processed + await prisma.watchEvent.update({ + where: { id: watchEvent.id }, + data: { isProcessed: true } + }); + + // Emit real-time update via WebSocket + io.to(`user:${user.id}`).emit('credits_earned', { + amount: creditsEarned, + title: event.title, + transaction: { + id: transaction.id, + type: 'EARN', + amount: creditsEarned, + contentTitle: event.title, + createdAt: transaction.createdAt + } + }); + + res.json({ + success: true, + creditsEarned, + solanaSignature: signature, + message: `Minted ${creditsEarned} COOP for watching ${event.title}` + }); + } else { + res.status(500).json({ error: 'Failed to mint tokens' }); + } + }) +); + +// Test webhook endpoint +router.post('/test', + asyncHandler(async (req, res) => { + res.json({ + message: 'Webhook endpoint working', + timestamp: new Date().toISOString(), + body: req.body + }); + }) +); + +export { router as webhookRouter }; diff --git a/backend/src/services/socket.ts b/backend/src/services/socket.ts new file mode 100644 index 0000000..dcd5773 --- /dev/null +++ b/backend/src/services/socket.ts @@ -0,0 +1,87 @@ +import { Server, Socket } from 'socket.io'; +import jwt from 'jsonwebtoken'; +import { prisma } from '../utils/prisma'; + +const JWT_SECRET = process.env.JWT_SECRET || 'secret'; + +interface AuthenticatedSocket extends Socket { + userId?: string; + isAdmin?: boolean; +} + +export function setupSocketHandlers(io: Server) { + // Authentication middleware + io.use(async (socket: AuthenticatedSocket, next) => { + try { + const token = socket.handshake.auth.token; + + if (!token) { + return next(new Error('Authentication required')); + } + + const decoded = jwt.verify(token, JWT_SECRET) as any; + + const user = await prisma.user.findUnique({ + where: { id: decoded.userId }, + select: { id: true, isAdmin: true, isActive: true } + }); + + if (!user || !user.isActive) { + return next(new Error('User not found or inactive')); + } + + socket.userId = user.id; + socket.isAdmin = user.isAdmin; + + next(); + } catch (error) { + next(new Error('Invalid token')); + } + }); + + io.on('connection', (socket: AuthenticatedSocket) => { + console.log(`Client connected: ${socket.userId}`); + + // Join user-specific room + if (socket.userId) { + socket.join(`user:${socket.userId}`); + } + + // Join admin room if admin + if (socket.isAdmin) { + socket.join('admins'); + } + + // Handle subscription to transaction updates + socket.on('subscribe_transactions', () => { + if (socket.userId) { + socket.join(`transactions:${socket.userId}`); + console.log(`User ${socket.userId} subscribed to transactions`); + } + }); + + // Handle unsubscription + socket.on('unsubscribe_transactions', () => { + if (socket.userId) { + socket.leave(`transactions:${socket.userId}`); + } + }); + + // Handle disconnect + socket.on('disconnect', () => { + console.log(`Client disconnected: ${socket.userId}`); + }); + }); +} + +// Helper to emit to specific user +export function emitToUser(userId: string, event: string, data: any) { + const { io } = require('../index'); + io.to(`user:${userId}`).emit(event, data); +} + +// Helper to emit to all admins +export function emitToAdmins(event: string, data: any) { + const { io } = require('../index'); + io.to('admins').emit(event, data); +} diff --git a/backend/src/services/solana.ts b/backend/src/services/solana.ts new file mode 100644 index 0000000..a5fbea2 --- /dev/null +++ b/backend/src/services/solana.ts @@ -0,0 +1,220 @@ +import { + Connection, + PublicKey, + Keypair, + Transaction, + SystemProgram, + sendAndConfirmTransaction +} from '@solana/web3.js'; +import { + getOrCreateAssociatedTokenAccount, + createMintToInstruction, + createBurnInstruction, + getAccount, + TOKEN_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID +} from '@solana/spl-token'; +import bs58 from 'bs58'; +import { prisma } from '../utils/prisma'; + +const RPC_URL = process.env.SOLANA_RPC_URL || 'https://api.devnet.solana.com'; +const PROGRAM_ID = new PublicKey(process.env.SOLANA_PROGRAM_ID || 'CoopCredits111111111111111111111111111111111'); +const DECIMALS = parseInt(process.env.SOLANA_TOKEN_DECIMALS || '6'); + +// Backend mint authority keypair (stored securely) +let mintAuthority: Keypair | null = null; + +try { + if (process.env.SOLANA_MINT_AUTHORITY_KEYPAIR) { + const secretKey = bs58.decode(process.env.SOLANA_MINT_AUTHORITY_KEYPAIR); + mintAuthority = Keypair.fromSecretKey(secretKey); + } +} catch (error) { + console.warn('Mint authority not configured'); +} + +export const connection = new Connection(RPC_URL, 'confirmed'); + +// Get token mint address from program state +export async function getTokenMint(): Promise { + try { + // In a real implementation, you'd derive this from the program state + // For now, return from environment or stored config + const config = await prisma.systemSettings.findFirst(); + if (config) { + // Store/retrieve mint address from config + return null; // Placeholder + } + return null; + } catch (error) { + console.error('Failed to get token mint:', error); + return null; + } +} + +// Create a new Solana wallet for a user +export function createWallet(): { publicKey: string; secretKey: string } { + const keypair = Keypair.generate(); + + return { + publicKey: keypair.publicKey.toBase58(), + secretKey: bs58.encode(keypair.secretKey) + }; +} + +// Get or create token account for user +export async function getOrCreateTokenAccount( + userPublicKey: PublicKey, + mint: PublicKey +): Promise { + const tokenAccount = await getOrCreateAssociatedTokenAccount( + connection, + mintAuthority!, // payer + mint, + userPublicKey + ); + + return tokenAccount.address; +} + +// Mint tokens to user (called by backend after watch event) +export async function mintTokens( + userWalletAddress: string, + amount: number, + metadata: { + sessionId: string; + contentTitle: string; + watchDurationMinutes: number; + } +): Promise { + if (!mintAuthority) { + throw new Error('Mint authority not configured'); + } + + try { + const userPublicKey = new PublicKey(userWalletAddress); + const mint = await getTokenMint(); + + if (!mint) { + throw new Error('Token mint not found'); + } + + // Get or create user's token account + const tokenAccount = await getOrCreateTokenAccount(userPublicKey, mint); + + // Calculate amount with decimals + const amountWithDecimals = amount * Math.pow(10, DECIMALS); + + // Create mint instruction + const mintInstruction = createMintToInstruction( + mint, + tokenAccount, + mintAuthority.publicKey, + BigInt(Math.floor(amountWithDecimals)) + ); + + // Create and send transaction + const transaction = new Transaction().add(mintInstruction); + const signature = await sendAndConfirmTransaction( + connection, + transaction, + [mintAuthority] + ); + + console.log(`Minted ${amount} COOP to ${userWalletAddress}: ${signature}`); + + return signature; + } catch (error) { + console.error('Failed to mint tokens:', error); + return null; + } +} + +// Burn tokens from user (called when content request is approved) +export async function burnTokens( + userWalletAddress: string, + userSecretKey: string, + amount: number +): Promise { + try { + const userKeypair = Keypair.fromSecretKey(bs58.decode(userSecretKey)); + const mint = await getTokenMint(); + + if (!mint) { + throw new Error('Token mint not found'); + } + + const userPublicKey = new PublicKey(userWalletAddress); + const tokenAccount = await getOrCreateTokenAccount(userPublicKey, mint); + + // Check balance + const accountInfo = await getAccount(connection, tokenAccount); + const amountWithDecimals = BigInt(Math.floor(amount * Math.pow(10, DECIMALS))); + + if (accountInfo.amount < amountWithDecimals) { + throw new Error('Insufficient balance'); + } + + // Create burn instruction + const burnInstruction = createBurnInstruction( + tokenAccount, + mint, + userKeypair.publicKey, + amountWithDecimals + ); + + const transaction = new Transaction().add(burnInstruction); + const signature = await sendAndConfirmTransaction( + connection, + transaction, + [userKeypair] + ); + + console.log(`Burned ${amount} COOP from ${userWalletAddress}: ${signature}`); + + return signature; + } catch (error) { + console.error('Failed to burn tokens:', error); + return null; + } +} + +// Get token balance for user +export async function getTokenBalance(walletAddress: string): Promise { + try { + const mint = await getTokenMint(); + if (!mint) return 0; + + const userPublicKey = new PublicKey(walletAddress); + + try { + const tokenAccount = await getOrCreateAssociatedTokenAccount( + connection, + mintAuthority!, + mint, + userPublicKey + ); + + const accountInfo = await getAccount(connection, tokenAccount.address); + return Number(accountInfo.amount) / Math.pow(10, DECIMALS); + } catch { + return 0; + } + } catch (error) { + console.error('Failed to get token balance:', error); + return 0; + } +} + +// Request airdrop for testing (devnet only) +export async function requestAirdrop(walletAddress: string): Promise { + try { + const publicKey = new PublicKey(walletAddress); + const signature = await connection.requestAirdrop(publicKey, 2 * 1000000000); // 2 SOL + await connection.confirmTransaction(signature); + return signature; + } catch (error) { + console.error('Airdrop failed:', error); + return null; + } +} diff --git a/backend/src/utils/prisma.ts b/backend/src/utils/prisma.ts new file mode 100644 index 0000000..227362e --- /dev/null +++ b/backend/src/utils/prisma.ts @@ -0,0 +1,11 @@ +import { PrismaClient } from '@prisma/client'; + +const globalForPrisma = globalThis as unknown as { + prisma: PrismaClient | undefined; +}; + +export const prisma = globalForPrisma.prisma ?? new PrismaClient({ + log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], +}); + +if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma; diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..e4cc8d2 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/deployment/deploy-production.sh b/deployment/deploy-production.sh new file mode 100755 index 0000000..e0cfed4 --- /dev/null +++ b/deployment/deploy-production.sh @@ -0,0 +1,258 @@ +#!/bin/bash + +# ========================================== +# CoopCredits Production Deployment Script +# For infrastructure: 172.20.1.0/24 +# ========================================== + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +echo -e "${BLUE}" +echo "🚀 CoopCredits Production Deployment" +echo "====================================" +echo -e "${NC}" +echo "Time: $(date)" +echo "Project: $PROJECT_DIR" +echo "" + +# ========================================== +# Pre-deployment Checks +# ========================================== + +pre_deployment_checks() { + echo -e "${BLUE}Running pre-deployment checks...${NC}" + + # Check environment file + if [ ! -f "$PROJECT_DIR/.env" ]; then + echo -e "${RED}❌ .env file not found!${NC}" + echo "Run: ./deployment/setup-infrastructure.sh" + exit 1 + fi + + # Check SSL certificates + if [ ! -f "$PROJECT_DIR/docker/nginx/ssl/cert.pem" ] && [ ! -d "$PROJECT_DIR/docker/nginx/letsencrypt" ]; then + echo -e "${YELLOW}⚠️ SSL certificates not found${NC}" + echo "Place certificates in docker/nginx/ssl/ or setup Let's Encrypt" + read -p "Continue without SSL? (y/N) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi + fi + + # Check Docker + if ! command -v docker &> /dev/null; then + echo -e "${RED}❌ Docker not installed${NC}" + exit 1 + fi + + if ! command -v docker-compose &> /dev/null; then + echo -e "${RED}❌ Docker Compose not installed${NC}" + exit 1 + fi + + # Check required env variables + source "$PROJECT_DIR/.env" + + local required_vars=( + "JWT_SECRET" + "TAUTULLI_API_KEY" + "OVERSEER_API_KEY" + "SOLANA_MINT_AUTHORITY_KEYPAIR" + "ENCRYPTION_KEY" + ) + + local missing=0 + for var in "${required_vars[@]}"; do + if [ -z "${!var}" ]; then + echo -e "${RED}❌ Missing required environment variable: $var${NC}" + missing=1 + fi + done + + if [ $missing -eq 1 ]; then + echo "Please edit $PROJECT_DIR/.env and add the missing values" + exit 1 + fi + + echo -e "${GREEN}✅ Pre-deployment checks passed${NC}" + echo "" +} + +# ========================================== +# Backup Current State +# ========================================== + +backup_state() { + echo -e "${BLUE}Creating backup...${NC}" + + local backup_dir="$PROJECT_DIR/backups/$(date +%Y%m%d_%H%M%S)" + mkdir -p "$backup_dir" + + # Backup database if running + if docker ps | grep -q "coop-postgres"; then + echo "Backing up database..." + docker exec coop-postgres pg_dump -U coop coop_credits > "$backup_dir/database.sql" 2>/dev/null || true + fi + + # Backup environment + cp "$PROJECT_DIR/.env" "$backup_dir/" + + # Backup Docker volumes list + docker volume ls | grep coop > "$backup_dir/volumes.txt" 2>/dev/null || true + + echo -e "${GREEN}✅ Backup created: $backup_dir${NC}" + echo "" +} + +# ========================================== +# Build and Deploy +# ========================================== + +deploy() { + echo -e "${BLUE}Building and deploying...${NC}" + + cd "$PROJECT_DIR" + + # Pull latest images + echo "Pulling base images..." + docker-compose -f docker-compose.prod.yml pull + + # Build services + echo "Building services..." + docker-compose -f docker-compose.prod.yml build --no-cache + + # Stop existing services + echo "Stopping existing services..." + docker-compose -f docker-compose.prod.yml down --remove-orphans + + # Start services + echo "Starting services..." + docker-compose -f docker-compose.prod.yml up -d + + # Wait for database + echo "Waiting for database..." + sleep 10 + + # Run migrations + echo "Running database migrations..." + docker-compose -f docker-compose.prod.yml exec -T backend npx prisma migrate deploy || { + echo -e "${YELLOW}⚠️ Migration may have already been applied${NC}" + } + + # Seed initial data if needed + echo "Checking for initial data..." + docker-compose -f docker-compose.prod.yml exec -T backend npx prisma db seed 2>/dev/null || true + + echo -e "${GREEN}✅ Deployment complete!${NC}" + echo "" +} + +# ========================================== +# Post-deployment Verification +# ========================================== + +verify_deployment() { + echo -e "${BLUE}Verifying deployment...${NC}" + + local retries=30 + local delay=2 + + # Wait for services to be ready + echo "Waiting for services to start..." + for i in $(seq 1 $retries); do + if curl -sf https://coop.hobokenchicken.com/health > /dev/null 2>&1; then + echo -e "${GREEN}✅ Website is responding${NC}" + break + fi + + if [ $i -eq $retries ]; then + echo -e "${RED}❌ Website failed to start${NC}" + echo "Check logs: docker-compose -f docker-compose.prod.yml logs" + exit 1 + fi + + echo -n "." + sleep $delay + done + + # Check database connection + if docker-compose -f docker-compose.prod.yml exec -T postgres pg_isready -U coop > /dev/null 2>&1; then + echo -e "${GREEN}✅ Database is ready${NC}" + else + echo -e "${RED}❌ Database not responding${NC}" + fi + + echo "" +} + +# ========================================== +# Display Info +# ========================================== + +show_info() { + echo -e "${BLUE}Deployment Information${NC}" + echo "======================" + echo "" + echo "Website: https://coop.hobokenchicken.com" + echo "API: https://coop.hobokenchicken.com/api" + echo "Webhooks: https://coop.hobokenchicken.com/webhooks/" + echo "" + echo "Services:" + docker-compose -f "$PROJECT_DIR/docker-compose.prod.yml" ps + echo "" + echo "Useful commands:" + echo " View logs: docker-compose -f docker-compose.prod.yml logs -f" + echo " Stop: docker-compose -f docker-compose.prod.yml down" + echo " Restart: docker-compose -f docker-compose.prod.yml restart" + echo " Health check: ./deployment/health-check.sh" + echo "" + echo -e "${GREEN}🎉 Deployment successful!${NC}" +} + +# ========================================== +# Main +# ========================================== + +main() { + pre_deployment_checks + backup_state + deploy + verify_deployment + show_info +} + +# Handle script arguments +case "${1:-}" in + --check) + pre_deployment_checks + exit 0 + ;; + --backup) + backup_state + exit 0 + ;; + --help|-h) + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " --check Run pre-deployment checks only" + echo " --backup Create backup only" + echo " --help Show this help message" + echo "" + exit 0 + ;; + *) + main + ;; +esac diff --git a/deployment/deploy.sh b/deployment/deploy.sh new file mode 100755 index 0000000..8f522b5 --- /dev/null +++ b/deployment/deploy.sh @@ -0,0 +1,132 @@ +#!/bin/bash + +# CoopCredits Deployment Script +# Usage: ./deploy.sh [environment] + +set -e + +ENVIRONMENT=${1:-production} +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +echo "🚀 CoopCredits Deployment" +echo "========================" +echo "Environment: $ENVIRONMENT" +echo "" + +# Check prerequisites +check_prerequisites() { + echo "Checking prerequisites..." + + command -v docker >/dev/null 2>&1 || { echo "❌ Docker is required but not installed. Aborting." >&2; exit 1; } + command -v docker-compose >/dev/null 2>&1 || { echo "❌ Docker Compose is required but not installed. Aborting." >&2; exit 1; } + + echo "✅ Prerequisites met" +} + +# Load environment variables +load_env() { + if [ -f "$PROJECT_DIR/.env" ]; then + echo "Loading environment variables..." + set -a + source "$PROJECT_DIR/.env" + set +a + else + echo "⚠️ .env file not found. Using default values." + fi +} + +# Build and deploy +build_and_deploy() { + echo "" + echo "Building and deploying..." + + cd "$PROJECT_DIR" + + # Pull latest images + docker-compose pull + + # Build services + docker-compose build --no-cache + + # Start services + docker-compose up -d + + # Wait for database + echo "Waiting for database..." + sleep 10 + + # Run migrations + echo "Running database migrations..." + docker-compose exec -T backend npx prisma migrate deploy + + echo "✅ Deployment complete!" +} + +# Setup SSL certificates +setup_ssl() { + echo "" + echo "Setting up SSL certificates..." + + # Check if certificates exist + if [ ! -f "$PROJECT_DIR/docker/nginx/ssl/cert.pem" ]; then + echo "⚠️ SSL certificates not found at docker/nginx/ssl/" + echo "Please place your certificates:" + echo " - cert.pem (certificate)" + echo " - key.pem (private key)" + echo "" + read -p "Continue without SSL? (y/n) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi + fi +} + +# Health check +health_check() { + echo "" + echo "Running health checks..." + + # Check if services are running + if docker-compose ps | grep -q "Up"; then + echo "✅ Services are running" + else + echo "❌ Some services failed to start" + docker-compose ps + exit 1 + fi + + # Test API endpoint + if curl -sf http://localhost:3001/health > /dev/null; then + echo "✅ Backend is responding" + else + echo "❌ Backend is not responding" + exit 1 + fi + + echo "✅ Health checks passed" +} + +# Main deployment flow +main() { + check_prerequisites + load_env + setup_ssl + build_and_deploy + health_check + + echo "" + echo "🎉 CoopCredits has been deployed!" + echo "" + echo "Access your application:" + echo " - Website: https://coop.hobokenchicken.com" + echo " - API: https://coop.hobokenchicken.com/api" + echo "" + echo "Useful commands:" + echo " - View logs: docker-compose logs -f" + echo " - Stop: docker-compose down" + echo " - Restart: docker-compose restart" +} + +main "$@" diff --git a/deployment/health-check.sh b/deployment/health-check.sh new file mode 100755 index 0000000..f3ba383 --- /dev/null +++ b/deployment/health-check.sh @@ -0,0 +1,228 @@ +#!/bin/bash + +# ========================================== +# CoopCredits Health Check Script +# Monitors all services in the infrastructure +# ========================================== + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +# Configuration +PLEX_IP="172.20.1.220" +OVERSEER_IP="172.20.1.225" +TAUTULLI_IP="172.20.1.255" + +PLEX_PORT="32400" +OVERSEER_PORT="5055" +TAUTULLI_PORT="8181" + +# Track overall health +HEALTHY=0 +UNHEALTHY=0 + +print_header() { + echo "" + echo "🏥 CoopCredits Health Check" + echo "==========================" + echo "$(date)" + echo "" +} + +check_service() { + local name=$1 + local url=$2 + local expected_code=${3:-200} + + echo -n "Checking $name... " + + if curl -sf -o /dev/null -w "%{http_code}" "$url" 2>/dev/null | grep -q "$expected_code"; then + echo -e "${GREEN}✅ OK${NC}" + ((HEALTHY++)) + return 0 + else + echo -e "${RED}❌ FAIL${NC}" + ((UNHEALTHY++)) + return 1 + fi +} + +check_docker_service() { + local name=$1 + local container=$2 + + echo -n "Checking Docker container: $name... " + + if docker ps --format "{{.Names}}" | grep -q "^${container}$"; then + local status=$(docker inspect --format='{{.State.Status}}' "$container" 2>/dev/null) + if [ "$status" = "running" ]; then + echo -e "${GREEN}✅ Running${NC}" + ((HEALTHY++)) + return 0 + else + echo -e "${RED}❌ Not running (status: $status)${NC}" + ((UNHEALTHY++)) + return 1 + fi + else + echo -e "${RED}❌ Not found${NC}" + ((UNHEALTHY++)) + return 1 + fi +} + +check_network_connectivity() { + local name=$1 + local ip=$2 + local port=$3 + + echo -n "Network connectivity to $name ($ip:$port)... " + + if timeout 5 bash -c "/dev/null; then + echo -e "${GREEN}✅ Reachable${NC}" + ((HEALTHY++)) + return 0 + else + echo -e "${RED}❌ Unreachable${NC}" + ((UNHEALTHY++)) + return 1 + fi +} + +check_disk_space() { + echo "" + echo "📊 Disk Space" + echo "-------------" + + df -h / | awk 'NR==2 {printf "Root: %s used of %s (%s)\n", $3, $2, $5}' + + local usage=$(df / | awk 'NR==2 {print $5}' | sed 's/%//') + if [ "$usage" -gt 90 ]; then + echo -e "${RED}⚠️ Critical disk usage!${NC}" + ((UNHEALTHY++)) + elif [ "$usage" -gt 80 ]; then + echo -e "${YELLOW}⚠️ High disk usage${NC}" + else + echo -e "${GREEN}✅ Disk usage OK${NC}" + ((HEALTHY++)) + fi +} + +check_memory() { + echo "" + echo "🧠 Memory Usage" + echo "--------------" + + if command -v free >/dev/null 2>&1; then + free -h | grep "Mem:" | awk '{printf "Used: %s / %s\n", $3, $2}' + + local usage=$(free | grep "Mem:" | awk '{printf "%.0f", $3/$2 * 100}') + if [ "$usage" -gt 90 ]; then + echo -e "${RED}⚠️ Critical memory usage!${NC}" + ((UNHEALTHY++)) + elif [ "$usage" -gt 80 ]; then + echo -e "${YELLOW}⚠️ High memory usage${NC}" + else + echo -e "${GREEN}✅ Memory usage OK${NC}" + ((HEALTHY++)) + fi + else + echo "Memory check not available" + fi +} + +check_ssl_certificate() { + echo "" + echo "🔒 SSL Certificate" + echo "------------------" + + local domain="coop.hobokenchicken.com" + local expiry=$(echo | openssl s_client -servername "$domain" -connect "$domain:443" 2>/dev/null | openssl x509 -noout -dates | grep notAfter | cut -d= -f2) + + if [ -n "$expiry" ]; then + local expiry_epoch=$(date -d "$expiry" +%s 2>/dev/null || date -j -f "%b %d %H:%M:%S %Y %Z" "$expiry" +%s) + local now_epoch=$(date +%s) + local days_left=$(( (expiry_epoch - now_epoch) / 86400 )) + + echo "Certificate expires: $expiry ($days_left days)" + + if [ "$days_left" -lt 7 ]; then + echo -e "${RED}⚠️ Certificate expires soon!${NC}" + ((UNHEALTHY++)) + elif [ "$days_left" -lt 30 ]; then + echo -e "${YELLOW}⚠️ Certificate expires in $days_left days${NC}" + else + echo -e "${GREEN}✅ Certificate valid${NC}" + ((HEALTHY++)) + fi + else + echo -e "${YELLOW}⚠️ Could not check certificate${NC}" + fi +} + +run_checks() { + print_header + + # Docker Services + echo "🐳 Docker Services" + echo "------------------" + check_docker_service "Nginx" "coop-nginx" + check_docker_service "Frontend" "coop-frontend" + check_docker_service "Backend" "coop-backend" + check_docker_service "PostgreSQL" "coop-postgres" + check_docker_service "Redis" "coop-redis" + + # Network Connectivity + echo "" + echo "🌐 Network Connectivity" + echo "-----------------------" + check_network_connectivity "Plex" "$PLEX_IP" "$PLEX_PORT" + check_network_connectivity "Tautulli" "$TAUTULLI_IP" "$TAUTULLI_PORT" + check_network_connectivity "Overseer" "$OVERSEER_IP" "$OVERSEER_PORT" + + # External Services + echo "" + echo "🔗 External Services" + echo "--------------------" + check_service "CoopCredits Website" "https://coop.hobokenchicken.com/health" + check_service "Backend API" "https://coop.hobokenchicken.com/api/health" + + # System Resources + check_disk_space + check_memory + + # SSL + check_ssl_certificate + + # Summary + echo "" + echo "📋 Summary" + echo "----------" + echo -e "${GREEN}✅ $HEALTHY healthy${NC}" + if [ $UNHEALTHY -gt 0 ]; then + echo -e "${RED}❌ $UNHEALTHY unhealthy${NC}" + exit 1 + else + echo -e "${GREEN}All systems operational!${NC}" + exit 0 + fi +} + +# Run in watch mode if requested +if [ "$1" == "--watch" ]; then + while true; do + clear + run_checks + echo "" + echo "Refreshing in 30 seconds... (Ctrl+C to stop)" + sleep 30 + done +else + run_checks +fi diff --git a/deployment/setup-infrastructure.sh b/deployment/setup-infrastructure.sh new file mode 100755 index 0000000..ba9fb0c --- /dev/null +++ b/deployment/setup-infrastructure.sh @@ -0,0 +1,357 @@ +#!/bin/bash + +# ========================================== +# CoopCredits Infrastructure Setup Script +# Configures integration with existing services +# Server Infrastructure: 172.20.1.0/24 +# ========================================== + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +echo "🏗️ CoopCredits Infrastructure Setup" +echo "====================================" +echo "" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# ========================================== +# Configuration +# ========================================== +PLEX_IP="172.20.1.220" +OVERSEER_IP="172.20.1.225" +TAUTULLI_IP="172.20.1.255" + +PLEX_PORT="32400" +OVERSEER_PORT="5055" +TAUTULLI_PORT="8181" + +echo "📋 Configuration:" +echo " Plex: http://${PLEX_IP}:${PLEX_PORT}" +echo " Overseer: http://${OVERSEER_IP}:${OVERSEER_PORT}" +echo " Tautulli: http://${TAUTULLI_IP}:${TAUTULLI_PORT}" +echo "" + +# ========================================== +# Test Connectivity +# ========================================== +test_connectivity() { + echo "🔍 Testing connectivity to services..." + + # Test Plex + if curl -sf "http://${PLEX_IP}:${PLEX_PORT}/identity" > /dev/null 2>&1; then + echo -e "${GREEN}✅${NC} Plex is reachable" + else + echo -e "${YELLOW}⚠️${NC} Plex not responding (may need auth or be offline)" + fi + + # Test Tautulli + if curl -sf "http://${TAUTULLI_IP}:${TAUTULLI_PORT}/api/v2?apikey=test&cmd=get_server_info" > /dev/null 2>&1; then + echo -e "${GREEN}✅${NC} Tautulli is reachable" + else + echo -e "${YELLOW}⚠️${NC} Tautulli not responding (check API key)" + fi + + # Test Overseer + if curl -sf "http://${OVERSEER_IP}:${OVERSEER_PORT}/api/v1/status" > /dev/null 2>&1; then + echo -e "${GREEN}✅${NC} Overseer is reachable" + else + echo -e "${YELLOW}⚠️${NC} Overseer not responding (may need API key)" + fi + + echo "" +} + +# ========================================== +# Generate Environment File +# ========================================== +generate_env() { + echo "📝 Generating environment configuration..." + + ENV_FILE="${PROJECT_DIR}/.env" + + if [ -f "$ENV_FILE" ]; then + echo -e "${YELLOW}⚠️${NC} .env file already exists. Creating backup..." + cp "$ENV_FILE" "${ENV_FILE}.backup.$(date +%Y%m%d_%H%M%S)" + fi + + # Generate secure keys + JWT_SECRET=$(openssl rand -hex 32) + ENCRYPTION_KEY=$(openssl rand -base64 32) + WEBHOOK_SECRET=$(openssl rand -hex 16) + POSTGRES_PASSWORD=$(openssl rand -base64 24 | tr -d '=+/') + REDIS_PASSWORD=$(openssl rand -base64 24 | tr -d '=+/') + + cat > "$ENV_FILE" << EOF +# ========================================== +# CoopCredits Environment Configuration +# Generated: $(date) +# Network: 172.20.1.0/24 +# ========================================== + +# Database +DATABASE_URL="postgresql://coop:${POSTGRES_PASSWORD}@postgres:5432/coop_credits?schema=public" +POSTGRES_PASSWORD="${POSTGRES_PASSWORD}" + +# Server +PORT=3001 +NODE_ENV=production +API_URL=https://coop.hobokenchicken.com +FRONTEND_URL=https://coop.hobokenchicken.com + +# JWT Secret +JWT_SECRET="${JWT_SECRET}" + +# ========================================== +# Plex Server (172.20.1.220:32400) +# ========================================== +# Get from: https://plex.tv/claim +PLEX_CLIENT_ID="" +PLEX_CLIENT_SECRET="" +PLEX_REDIRECT_URI="https://coop.hobokenchicken.com/auth/callback" + +# ========================================== +# Tautulli (172.20.1.255:8181) +# ========================================== +# Get from: Settings > Web Interface > API > API Key +TAUTULLI_URL="http://${TAUTULLI_IP}:${TAUTULLI_PORT}" +TAUTULLI_API_KEY="" +TAUTULLI_WEBHOOK_SECRET="${WEBHOOK_SECRET}" + +# ========================================== +# Overseer (172.20.1.225:5055) +# ========================================== +# Get from: Settings > General > API Key +OVERSEER_URL="http://${OVERSEER_IP}:${OVERSEER_PORT}" +OVERSEER_API_KEY="" + +# ========================================== +# Solana (Devnet) +# ========================================== +# Run: npm run setup:solana to generate +SOLANA_RPC_URL="https://api.devnet.solana.com" +SOLANA_PROGRAM_ID="CoopCredits111111111111111111111111111111111" +SOLANA_MINT_AUTHORITY_KEYPAIR="" +SOLANA_TOKEN_DECIMALS=6 + +# ========================================== +# Redis +# ========================================== +REDIS_URL="redis://:${REDIS_PASSWORD}@redis:6379" +REDIS_PASSWORD="${REDIS_PASSWORD}" + +# ========================================== +# Encryption +# ========================================== +ENCRYPTION_KEY="${ENCRYPTION_KEY}" + +# ========================================== +# Frontend +# ========================================== +NEXT_PUBLIC_API_URL="https://coop.hobokenchicken.com" +NEXT_PUBLIC_SOLANA_NETWORK="devnet" +NEXT_PUBLIC_SOLANA_RPC_URL="https://api.devnet.solana.com" +NEXT_PUBLIC_APP_NAME="CoopCredits" +NEXT_PUBLIC_APP_URL="https://coop.hobokenchicken.com" +EOF + + echo -e "${GREEN}✅${NC} Environment file created: ${ENV_FILE}" + echo "" + echo -e "${YELLOW}⚠️${NC} IMPORTANT: Edit .env and add your API keys:" + echo " 1. PLEX_CLIENT_ID and PLEX_CLIENT_SECRET from plex.tv" + echo " 2. TAUTULLI_API_KEY from Tautulli settings" + echo " 3. OVERSEER_API_KEY from Overseer settings" + echo " 4. SOLANA_MINT_AUTHORITY_KEYPAIR from 'npm run setup:solana'" + echo "" +} + +# ========================================== +# Configure Tautulli Webhook +# ========================================== +configure_tautulli() { + echo "📺 Tautulli Webhook Configuration" + echo "==================================" + echo "" + echo "To complete Tautulli integration:" + echo "" + echo "1. Open Tautulli: http://${TAUTULLI_IP}:${TAUTULLI_PORT}" + echo "2. Go to: Settings > Notification Agents" + echo "3. Click 'Add a new notification agent'" + echo "4. Select 'Webhook'" + echo "" + echo "Configure the webhook:" + echo " Webhook URL: https://coop.hobokenchicken.com/webhooks/tautulli" + echo " Webhook Method: POST" + echo " Content Type: application/json" + echo "" + echo "JSON Payload (copy this exactly):" + cat << 'JSONEOF' +{ + "action": "watched", + "user_id": "{user_id}", + "username": "{username}", + "rating_key": "{rating_key}", + "session_key": "{session_key}", + "media_type": "{media_type}", + "title": "{title}", + "grandparent_title": "{grandparent_title}", + "started": "{started}", + "stopped": "{stopped}", + "percent_complete": "{percent_complete}", + "is_new": "{is_new}" +} +JSONEOF + echo "" + echo "Triggers: Enable 'Watched'" + echo "Conditions: None (or customize as needed)" + echo "" +} + +# ========================================== +# Configure Overseer Webhook +# ========================================== +configure_overseer() { + echo "🎬 Overseer Webhook Configuration" + echo "==================================" + echo "" + echo "To complete Overseer integration:" + echo "" + echo "1. Open Overseer: http://${OVERSEER_IP}:${OVERSEER_PORT}" + echo "2. Go to: Settings > Notifications" + echo "3. Click 'Webhook'" + echo "" + echo "Configure the webhook:" + echo " Webhook URL: https://coop.hobokenchicken.com/webhooks/overseer" + echo " Authorization Header: Bearer " + echo "" + echo "JSON Payload:" + cat << 'JSONEOF' +{ + "request_id": "{{request.id}}", + "status": "{{request.status}}", + "media_type": "{{media.media_type}}", + "title": "{{media.title}}" +} +JSONEOF + echo "" + echo "Events: Enable 'Request Approved' and 'Request Declined'" + echo "" +} + +# ========================================== +# Configure Plex OAuth +# ========================================== +configure_plex() { + echo "🎭 Plex OAuth Configuration" + echo "===========================" + echo "" + echo "To enable Plex authentication:" + echo "" + echo "1. Go to: https://plex.tv/claim" + echo " Or: https://www.plex.tv/link/" + echo "" + echo "2. For OAuth app registration, you may need to contact Plex" + echo " or use a third-party OAuth provider that supports Plex" + echo "" + echo "3. Alternative: Use custom authentication with Plex credentials" + echo " stored securely and linked to wallets" + echo "" +} + +# ========================================== +# Firewall Rules +# ========================================== +configure_firewall() { + echo "🔥 Firewall Configuration" + echo "=========================" + echo "" + echo "Recommended firewall rules for CoopCredits server:" + echo "" + echo "# Allow web traffic" + echo "sudo ufw allow 80/tcp" + echo "sudo ufw allow 443/tcp" + echo "" + echo "# Allow internal network access to services" + echo "sudo ufw allow from 172.20.1.0/24 to any port 3001" + echo "" + echo "# Allow PostgreSQL only from localhost" + echo "sudo ufw allow from 127.0.0.1 to any port 5432" + echo "" + echo "# Allow Redis only from localhost" + echo "sudo ufw allow from 127.0.0.1 to any port 6379" + echo "" + echo "Current UFW status:" + sudo ufw status verbose 2>/dev/null || echo "UFW not installed or not active" + echo "" +} + +# ========================================== +# SSL Certificate Setup +# ========================================== +setup_ssl() { + echo "🔒 SSL Certificate Setup" + echo "=======================" + echo "" + + SSL_DIR="${PROJECT_DIR}/docker/nginx/ssl" + mkdir -p "$SSL_DIR" + + if [ -f "${SSL_DIR}/cert.pem" ] && [ -f "${SSL_DIR}/key.pem" ]; then + echo -e "${GREEN}✅${NC} SSL certificates already exist" + else + echo "SSL certificates not found at ${SSL_DIR}/" + echo "" + echo "Options:" + echo "" + echo "1. Let's Encrypt (recommended for production):" + echo " docker-compose -f docker-compose.prod.yml run --rm certbot certonly" + echo " --webroot -w /var/www/certbot" + echo " -d coop.hobokenchicken.com" + echo " --agree-tos --no-eff-email" + echo "" + echo "2. Self-signed (for testing only):" + echo " openssl req -x509 -nodes -days 365 -newkey rsa:2048 \\" + echo " -keyout ${SSL_DIR}/key.pem \\" + echo " -out ${SSL_DIR}/cert.pem \\" + echo " -subj '/CN=coop.hobokenchicken.com'" + echo "" + echo "3. Place existing certificates:" + echo " cp your-cert.pem ${SSL_DIR}/cert.pem" + echo " cp your-key.pem ${SSL_DIR}/key.pem" + echo "" + fi +} + +# ========================================== +# Main +# ========================================== +main() { + test_connectivity + generate_env + setup_ssl + configure_tautulli + configure_overseer + configure_plex + configure_firewall + + echo "" + echo "🎉 Setup complete!" + echo "==================" + echo "" + echo "Next steps:" + echo " 1. Edit .env file with your API keys" + echo " 2. Run: npm run setup:solana" + echo " 3. Setup SSL certificates" + echo " 4. Run: npm run deploy" + echo "" + echo "After deployment, configure webhooks in Tautulli and Overseer" + echo "" +} + +main "$@" diff --git a/deployment/setup-solana.sh b/deployment/setup-solana.sh new file mode 100755 index 0000000..d419e0d --- /dev/null +++ b/deployment/setup-solana.sh @@ -0,0 +1,85 @@ +#!/bin/bash + +# Solana Setup Script for CoopCredits +# This script sets up the Solana environment for the CoopCredits token + +set -e + +echo "🪙 CoopCredits Solana Setup" +echo "===========================" +echo "" + +# Check for Solana CLI +if ! command -v solana &> /dev/null; then + echo "Installing Solana CLI..." + sh -c "$(curl -sSfL https://release.solana.com/v1.17.0/install)" + export PATH="$HOME/.local/share/solana/install/active_release/bin:$PATH" +fi + +echo "✅ Solana CLI version: $(solana --version)" + +# Set network to devnet +echo "" +echo "Setting network to devnet..." +solana config set --url devnet + +# Create or load keypair +KEYPAIR_PATH="$HOME/.config/solana/coop-credits-authority.json" + +if [ -f "$KEYPAIR_PATH" ]; then + echo "✅ Using existing keypair: $KEYPAIR_PATH" +else + echo "Creating new keypair..." + solana-keygen new --outfile "$KEYPAIR_PATH" --no-bip39-passphrase + echo "✅ Keypair created: $KEYPAIR_PATH" +fi + +# Get public key +AUTHORITY_PUBKEY=$(solana-keygen pubkey "$KEYPAIR_PATH") +echo "" +echo "Authority Public Key: $AUTHORITY_PUBKEY" + +# Request airdrop +echo "" +echo "Requesting airdrop..." +solana airdrop 2 "$AUTHORITY_PUBKEY" || echo "⚠️ Airdrop failed (may have rate limit)" + +# Check balance +BALANCE=$(solana balance "$AUTHORITY_PUBKEY") +echo "Authority Balance: $BALANCE SOL" + +# Export private key for backend +echo "" +echo "Exporting private key for backend..." +PRIVATE_KEY=$(cat "$KEYPAIR_PATH" | jq -r '.[]' | xxd -p -c 999999 | head -c 128) +BASE58_KEY=$(solana-keygen recover --force --outfile /dev/stdout "$KEYPAIR_PATH" 2>/dev/null | head -1) + +echo "" +echo "================================================" +echo "IMPORTANT: Add these to your backend .env file:" +echo "================================================" +echo "" +echo "SOLANA_MINT_AUTHORITY_KEYPAIR=$BASE58_KEY" +echo "" +echo "================================================" +echo "" + +# Build Anchor program +echo "Building Anchor program..." +cd ../anchor-program + +if command -v anchor &> /dev/null; then + anchor build + echo "✅ Program built successfully" + + echo "" + echo "To deploy the program, run:" + echo " cd anchor-program" + echo " anchor deploy" +else + echo "⚠️ Anchor not installed. Install with:" + echo " npm install -g @coral-xyz/anchor-cli" +fi + +echo "" +echo "✅ Setup complete!" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..98b9e09 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,220 @@ +version: '3.8' + +# ========================================== +# CoopCredits Production Docker Compose +# Server Infrastructure: 172.20.1.0/24 +# ========================================== + +services: + # ========================================== + # PostgreSQL Database + # ========================================== + postgres: + image: postgres:16-alpine + container_name: coop-postgres + environment: + POSTGRES_USER: coop + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-coop_secure_password_change_me} + POSTGRES_DB: coop_credits + volumes: + - postgres_data:/var/lib/postgresql/data + - ./docker/postgres/init:/docker-entrypoint-initdb.d + ports: + - "127.0.0.1:5432:5432" # Only accessible locally + healthcheck: + test: ["CMD-SHELL", "pg_isready -U coop -d coop_credits"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - coop-internal + restart: unless-stopped + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # ========================================== + # Redis Cache + # ========================================== + redis: + image: redis:7-alpine + container_name: coop-redis + command: redis-server --requirepass ${REDIS_PASSWORD:-redis_secure_password} + volumes: + - redis_data:/data + ports: + - "127.0.0.1:6379:6379" # Only accessible locally + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - coop-internal + restart: unless-stopped + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # ========================================== + # Backend API Server + # ========================================== + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: coop-backend + environment: + - NODE_ENV=production + - DATABASE_URL=postgresql://coop:${POSTGRES_PASSWORD:-coop_secure_password}@postgres:5432/coop_credits?schema=public + - REDIS_URL=redis://:${REDIS_PASSWORD:-redis_secure_password}@redis:6379 + - PORT=3001 + - JWT_SECRET=${JWT_SECRET} + - API_URL=https://coop.hobokenchicken.com + - FRONTEND_URL=https://coop.hobokenchicken.com + # Plex + - PLEX_CLIENT_ID=${PLEX_CLIENT_ID} + - PLEX_CLIENT_SECRET=${PLEX_CLIENT_SECRET} + - PLEX_REDIRECT_URI=${PLEX_REDIRECT_URI:-https://coop.hobokenchicken.com/auth/callback} + # Tautulli (172.20.1.255:8181) + - TAUTULLI_URL=http://172.20.1.255:8181 + - TAUTULLI_API_KEY=${TAUTULLI_API_KEY} + - TAUTULLI_WEBHOOK_SECRET=${TAUTULLI_WEBHOOK_SECRET} + # Overseer (172.20.1.225:5055) + - OVERSEER_URL=http://172.20.1.225:5055 + - OVERSEER_API_KEY=${OVERSEER_API_KEY} + # Solana + - SOLANA_RPC_URL=${SOLANA_RPC_URL:-https://api.devnet.solana.com} + - SOLANA_PROGRAM_ID=${SOLANA_PROGRAM_ID} + - SOLANA_MINT_AUTHORITY_KEYPAIR=${SOLANA_MINT_AUTHORITY_KEYPAIR} + - SOLANA_TOKEN_DECIMALS=6 + # Security + - ENCRYPTION_KEY=${ENCRYPTION_KEY} + - TRUST_PROXY=true + ports: + - "127.0.0.1:3001:3001" # Only accessible via Nginx + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - coop-internal + # Allow access to local network for Tautulli/Overseer/Plex + - coop-external + restart: unless-stopped + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "5" + # Health check + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3001/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + # ========================================== + # Frontend Next.js App + # ========================================== + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + - NEXT_PUBLIC_API_URL=https://coop.hobokenchicken.com + - NEXT_PUBLIC_SOLANA_NETWORK=devnet + - NEXT_PUBLIC_SOLANA_RPC_URL=https://api.devnet.solana.com + container_name: coop-frontend + environment: + - NODE_ENV=production + - NEXT_PUBLIC_API_URL=https://coop.hobokenchicken.com + - NEXT_PUBLIC_SOLANA_NETWORK=devnet + - NEXT_PUBLIC_SOLANA_RPC_URL=https://api.devnet.solana.com + ports: + - "127.0.0.1:3000:3000" # Only accessible via Nginx + depends_on: + - backend + networks: + - coop-internal + restart: unless-stopped + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "5" + + # ========================================== + # Nginx Reverse Proxy + # ========================================== + nginx: + image: nginx:alpine + container_name: coop-nginx + ports: + - "80:80" + - "443:443" + volumes: + - ./docker/nginx/nginx.prod.conf:/etc/nginx/nginx.conf:ro + - ./docker/nginx/ssl:/etc/nginx/ssl:ro + - ./docker/nginx/logs:/var/log/nginx + # Let's Encrypt certificates (if using certbot) + - ./docker/nginx/letsencrypt:/etc/letsencrypt:ro + - ./docker/nginx/www:/var/www/certbot:ro + depends_on: + - frontend + - backend + networks: + - coop-internal + restart: unless-stopped + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "5" + + # ========================================== + # Certbot (for Let's Encrypt SSL) + # ========================================== + certbot: + image: certbot/certbot + container_name: coop-certbot + volumes: + - ./docker/nginx/letsencrypt:/etc/letsencrypt + - ./docker/nginx/www:/var/www/certbot + entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" + networks: + - coop-internal + restart: unless-stopped + +# ========================================== +# Volumes +# ========================================== +volumes: + postgres_data: + driver: local + redis_data: + driver: local + +# ========================================== +# Networks +# ========================================== +networks: + # Internal network for container communication + coop-internal: + driver: bridge + internal: false + + # External network for accessing local services (172.20.1.0/24) + coop-external: + driver: bridge + ipam: + config: + - subnet: 172.20.2.0/24 + gateway: 172.20.2.1 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9679f70 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,109 @@ +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + container_name: coop-postgres + environment: + POSTGRES_USER: coop + POSTGRES_PASSWORD: coop_password + POSTGRES_DB: coop_credits + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U coop -d coop_credits"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - coop-network + + redis: + image: redis:7-alpine + container_name: coop-redis + volumes: + - redis_data:/data + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - coop-network + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: coop-backend + environment: + - NODE_ENV=production + - DATABASE_URL=postgresql://coop:coop_password@postgres:5432/coop_credits?schema=public + - REDIS_URL=redis://redis:6379 + - PORT=3001 + - JWT_SECRET=${JWT_SECRET} + - SOLANA_RPC_URL=${SOLANA_RPC_URL:-https://api.devnet.solana.com} + - SOLANA_PROGRAM_ID=${SOLANA_PROGRAM_ID} + - SOLANA_MINT_AUTHORITY_KEYPAIR=${SOLANA_MINT_AUTHORITY_KEYPAIR} + - PLEX_CLIENT_ID=${PLEX_CLIENT_ID} + - PLEX_CLIENT_SECRET=${PLEX_CLIENT_SECRET} + - TAUTULLI_URL=${TAUTULLI_URL} + - TAUTULLI_API_KEY=${TAUTULLI_API_KEY} + - OVERSEER_URL=${OVERSEER_URL} + - OVERSEER_API_KEY=${OVERSEER_API_KEY} + - ENCRYPTION_KEY=${ENCRYPTION_KEY} + ports: + - "3001:3001" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - coop-network + restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: coop-frontend + environment: + - NEXT_PUBLIC_API_URL=http://localhost:3001 + - NEXT_PUBLIC_SOLANA_NETWORK=devnet + - NEXT_PUBLIC_SOLANA_RPC_URL=https://api.devnet.solana.com + ports: + - "3000:3000" + depends_on: + - backend + networks: + - coop-network + restart: unless-stopped + + nginx: + image: nginx:alpine + container_name: coop-nginx + ports: + - "80:80" + - "443:443" + volumes: + - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./docker/nginx/ssl:/etc/nginx/ssl:ro + depends_on: + - frontend + - backend + networks: + - coop-network + restart: unless-stopped + +volumes: + postgres_data: + redis_data: + +networks: + coop-network: + driver: bridge diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf new file mode 100644 index 0000000..42ef4ac --- /dev/null +++ b/docker/nginx/nginx.conf @@ -0,0 +1,119 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Logging + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + error_log /var/log/nginx/error.log warn; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # Rate limiting + limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; + limit_req_zone $binary_remote_addr zone=webhooks:10m rate=100r/s; + + # Upstream servers + upstream frontend { + server frontend:3000; + } + + upstream backend { + server backend:3001; + } + + # HTTP server - redirect to HTTPS + server { + listen 80; + server_name coop.hobokenchicken.com; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$server_name$request_uri; + } + } + + # HTTPS server + server { + listen 443 ssl http2; + server_name coop.hobokenchicken.com; + + # SSL certificates (replace with your actual certificates) + ssl_certificate /etc/nginx/ssl/cert.pem; + ssl_certificate_key /etc/nginx/ssl/key.pem; + + # SSL settings + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Frontend + location / { + proxy_pass http://frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + # API + location /api/ { + limit_req zone=api burst=20 nodelay; + proxy_pass http://backend/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Webhooks (higher rate limit) + location /webhooks/ { + limit_req zone=webhooks burst=50 nodelay; + proxy_pass http://backend/webhooks/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # WebSocket support + location /socket.io/ { + proxy_pass http://backend/socket.io/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } +} diff --git a/docker/nginx/nginx.prod.conf b/docker/nginx/nginx.prod.conf new file mode 100644 index 0000000..7bf019b --- /dev/null +++ b/docker/nginx/nginx.prod.conf @@ -0,0 +1,206 @@ +# ========================================== +# CoopCredits Nginx Configuration +# Production - coop.hobokenchicken.com +# ========================================== + +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; + use epoll; + multi_accept on; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Logging format + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for" ' + 'rt=$request_time uct="$upstream_connect_time" ' + 'uht="$upstream_header_time" urt="$upstream_response_time"'; + + access_log /var/log/nginx/access.log main; + + # Performance + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 50M; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml application/json application/javascript + application/rss+xml application/atom+xml image/svg+xml; + + # Rate limiting zones + limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; + limit_req_zone $binary_remote_addr zone=webhooks:10m rate=100r/s; + limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m; + limit_conn_zone $binary_remote_addr zone=addr:10m; + + # Upstream servers + upstream frontend { + server frontend:3000 max_fails=3 fail_timeout=30s; + } + + upstream backend { + server backend:3001 max_fails=3 fail_timeout=30s; + } + + # ========================================== + # HTTP Server - Redirect to HTTPS + # ========================================== + server { + listen 80; + server_name coop.hobokenchicken.com; + + # Let's Encrypt challenge + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # Redirect all HTTP to HTTPS + location / { + return 301 https://$server_name$request_uri; + } + } + + # ========================================== + # HTTPS Server - Main Application + # ========================================== + server { + listen 443 ssl http2; + server_name coop.hobokenchicken.com; + + # SSL Certificates + ssl_certificate /etc/nginx/ssl/cert.pem; + ssl_certificate_key /etc/nginx/ssl/key.pem; + + # Alternative: Let's Encrypt certificates + # ssl_certificate /etc/letsencrypt/live/coop.hobokenchicken.com/fullchain.pem; + # ssl_certificate_key /etc/letsencrypt/live/coop.hobokenchicken.com/privkey.pem; + + # SSL Configuration + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + ssl_session_tickets off; + + # Security Headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://api.devnet.solana.com wss:;" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + + # ========================================== + # Frontend - Next.js App + # ========================================== + location / { + proxy_pass http://frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + # ========================================== + # API Endpoints + # ========================================== + location /api/ { + limit_req zone=api burst=20 nodelay; + limit_conn addr 10; + + proxy_pass http://backend/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 30s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + # ========================================== + # Webhooks (higher rate limit) + # ========================================== + location /webhooks/ { + limit_req zone=webhooks burst=50 nodelay; + + proxy_pass http://backend/webhooks/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 30s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + # ========================================== + # WebSocket Support (Socket.io) + # ========================================== + location /socket.io/ { + proxy_pass http://backend/socket.io/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 7d; + proxy_send_timeout 7d; + proxy_read_timeout 7d; + } + + # ========================================== + # Static Assets (cache optimization) + # ========================================== + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + proxy_pass http://frontend; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # ========================================== + # Health Check Endpoint + # ========================================== + location /health { + proxy_pass http://backend/health; + access_log off; + } + + # ========================================== + # Error Pages + # ========================================== + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + internal; + } + } +} diff --git a/docs/INFRASTRUCTURE.md b/docs/INFRASTRUCTURE.md new file mode 100644 index 0000000..8017833 --- /dev/null +++ b/docs/INFRASTRUCTURE.md @@ -0,0 +1,232 @@ +# CoopCredits Infrastructure Documentation + +## Network Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ External Access │ +│ coop.hobokenchicken.com │ +└─────────────────────────────────┬───────────────────────────────────────┘ + │ HTTPS (443) + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ CoopCredits Server │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Nginx │ │ Frontend │ │ Backend │ │ PostgreSQL │ │ +│ │ (80/443) │──│ (Next.js) │──│ (Express) │──│ (5432) │ │ +│ └──────────────┘ └──────────────┘ └──────┬───────┘ └──────────────┘ │ +│ │ │ +│ ┌──────────────┐ ┌──────────────┐ │ ┌──────────────┐ │ +│ │ Certbot │ │ Redis │◀────────┘ │ Anchor CLI │ │ +│ │ (SSL) │ │ (6379) │ │ (Optional) │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└──────────────────────────────────┬──────────────────────────────────────┘ + │ + │ Local Network (172.20.1.0/24) + ┌──────────────────────────┼──────────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Plex │ │ Tautulli │ │ Overseer │ +│172.20.1.220 │ │172.20.1.255 │ │172.20.1.225 │ +│ :32400 │ │ :8181 │ │ :5055 │ +└──────────────┘ └──────────────┘ └──────────────┘ +``` + +## Service Details + +### Plex Server (172.20.1.220:32400) +- **Purpose**: Content streaming and user authentication +- **Integration**: OAuth authentication for website login +- **Access**: HTTP on local network, may have remote access enabled + +### Tautulli (172.20.1.255:8181) +- **Purpose**: Plex analytics and watch event tracking +- **Integration**: Webhook notifications to CoopCredits backend +- **Access**: HTTP on local network +- **API Key**: Required for backend queries + +### Overseer (172.20.1.225:5055) +- **Purpose**: Content request management +- **Integration**: API for requesting content, webhooks for status updates +- **Access**: HTTP on local network +- **API Key**: Required for backend integration + +### CoopCredits Server +- **Public Access**: coop.hobokenchicken.com (HTTPS) +- **Internal Services**: Only accessible via Nginx reverse proxy +- **Database**: PostgreSQL on localhost only +- **Cache**: Redis on localhost only + +## Communication Flow + +### 1. User Authentication +``` +User → Nginx → Frontend → Backend → Plex OAuth (172.20.1.220:32400) + ↓ + User authenticated, JWT issued +``` + +### 2. Watch Event Processing +``` +Plex → Tautulli → Webhook → Nginx → Backend → Solana Devnet + ↓ + Database updated + ↓ + WebSocket → User notified +``` + +### 3. Content Request +``` +User → Nginx → Frontend → Backend → Overseer API (172.20.1.225:5055) + ↓ + Request created, $COOP reserved + ↓ + Webhook on approval → Burn $COOP +``` + +## Security Considerations + +### Network Security +1. **Local Network**: All services communicate over HTTP (trusted network) +2. **External Access**: Only Nginx exposed (ports 80/443) +3. **Internal Services**: Not accessible from external network + +### API Security +1. **Tautulli Webhook**: Secret verification recommended +2. **Overseer API**: API key authentication +3. **Plex OAuth**: Standard OAuth 2.0 flow +4. **JWT**: Secure tokens for session management + +### Data Security +1. **Wallet Keys**: Encrypted with AES-256-GCM in database +2. **Database**: Not exposed externally +3. **Redis**: Password protected, localhost only + +## Firewall Configuration + +```bash +# Web traffic +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp + +# Internal service access (from Docker containers) +sudo ufw allow from 172.20.0.0/16 to 172.20.1.0/24 + +# Block external access to internal services +sudo ufw deny 3000/tcp # Frontend +sudo ufw deny 3001/tcp # Backend +sudo ufw deny 5432/tcp # PostgreSQL +sudo ufw deny 6379/tcp # Redis +``` + +## Docker Network Configuration + +### Internal Network (coop-internal) +- Containers can communicate with each other +- Isolated from external network +- Used for: frontend ↔ backend ↔ database + +### External Network (coop-external) +- Allows containers to reach local services +- Subnet: 172.20.2.0/24 +- Used for: backend → Tautulli/Overseer/Plex + +## SSL/TLS Setup + +### Let's Encrypt (Recommended) +```bash +# Initial certificate +docker-compose -f docker-compose.prod.yml run --rm certbot certonly \ + --webroot -w /var/www/certbot \ + -d coop.hobokenchicken.com \ + --agree-tos --no-eff-email + +# Auto-renewal (configured in docker-compose) +``` + +### Self-Signed (Testing only) +```bash +openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout docker/nginx/ssl/key.pem \ + -out docker/nginx/ssl/cert.pem \ + -subj '/CN=coop.hobokenchicken.com' +``` + +## Monitoring and Logging + +### Log Locations +- Nginx: `docker/nginx/logs/` +- Backend: Docker logs (`docker-compose logs backend`) +- Frontend: Docker logs (`docker-compose logs frontend`) +- Database: Inside container (`/var/log/postgresql/`) + +### Health Checks +```bash +# Backend health +curl https://coop.hobokenchicken.com/health + +# Database connection +docker-compose exec postgres pg_isready -U coop + +# Service status +docker-compose ps +``` + +## Troubleshooting + +### Cannot reach local services +1. Check Docker network: `docker network inspect coop-credits_coop-external` +2. Verify IP connectivity: `docker exec coop-backend ping 172.20.1.255` +3. Check firewall rules: `sudo ufw status` + +### Webhook not received +1. Verify Tautulli can reach CoopCredits: + ```bash + curl -X POST https://coop.hobokenchicken.com/webhooks/tautulli \ + -H "Content-Type: application/json" \ + -d '{"test": true}' + ``` +2. Check Nginx logs: `tail -f docker/nginx/logs/access.log` +3. Check backend logs: `docker-compose logs -f backend` + +### CORS errors +1. Verify CORS_ORIGINS in .env includes your domain +2. Check backend is sending correct headers +3. Nginx should pass through CORS headers + +## Performance Optimization + +### Nginx Tuning +- `worker_processes auto` - Use all CPU cores +- `worker_connections 1024` - High connection limit +- `gzip on` - Compress responses +- `proxy_cache` - Cache static assets + +### Database Tuning +- Connection pooling via Prisma +- Redis for session caching +- Indexed queries on user_id, created_at + +### Frontend Optimization +- Next.js static generation where possible +- Image optimization +- Code splitting + +## Backup and Recovery + +### Database Backup +```bash +# Automated backup script +docker-compose exec -T postgres pg_dump -U coop coop_credits > backup_$(date +%Y%m%d).sql +``` + +### Wallet Recovery +- Private keys are encrypted in database +- Backup keys stored securely (encrypted) +- Recovery requires encryption key from .env + +### Configuration Backup +- `.env` file (contains all secrets) +- `docker/nginx/ssl/` certificates +- `docker-compose.prod.yml` service config diff --git a/docs/SETUP-INFRASTRUCTURE.md b/docs/SETUP-INFRASTRUCTURE.md new file mode 100644 index 0000000..b84056d --- /dev/null +++ b/docs/SETUP-INFRASTRUCTURE.md @@ -0,0 +1,372 @@ +# CoopCredits Setup for 172.20.1.0/24 Infrastructure + +This guide covers setting up CoopCredits with your existing Plex/Tautulli/Overseer infrastructure. + +## Prerequisites + +- Server running Docker and Docker Compose +- Access to 172.20.1.0/24 network +- API keys from Tautulli and Overseer +- Domain name (coop.hobokenchicken.com) pointing to your server + +## Network Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ CoopCredits Server │ +│ (Your Server IP) │ +│ :443 │ +└─────────────────────────────────┬───────────────────────────────┘ + │ HTTPS + ┌─────────────────────────┼──────────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Plex │ │ Tautulli │ │ Overseer │ +│172.20.1.220 │ │172.20.1.255 │ │172.20.1.225 │ +│ :32400 │ │ :8181 │ │ :5055 │ +└──────────────┘ └──────────────┘ └──────────────┘ +``` + +## Quick Start + +### 1. Clone Repository + +```bash +cd /opt +git clone coop-credits +cd coop-credits +``` + +### 2. Run Infrastructure Setup + +```bash +./deployment/setup-infrastructure.sh +``` + +This script will: +- Test connectivity to your services (Plex, Tautulli, Overseer) +- Generate a secure `.env` file +- Check SSL certificate status +- Output configuration instructions + +### 3. Configure Environment + +Edit the generated `.env` file: + +```bash +nano .env +``` + +Add your API keys: + +```env +# Get from Tautulli: Settings > Web Interface > API +TAUTULLI_API_KEY=your-tautulli-api-key + +# Get from Overseer: Settings > General > API Key +OVERSEER_API_KEY=your-overseer-api-key + +# Get from https://plex.tv/claim or Plex settings +PLEX_CLIENT_ID=your-plex-client-id +PLEX_CLIENT_SECRET=your-plex-client-secret +``` + +### 4. Setup Solana + +```bash +npm run setup:solana +``` + +This will: +- Install Solana CLI +- Create a devnet wallet +- Request airdrop +- Output the private key for your `.env` file + +Copy the `SOLANA_MINT_AUTHORITY_KEYPAIR` into your `.env` file. + +### 5. Deploy Solana Program + +```bash +cd anchor-program +anchor build +anchor deploy +``` + +Update `SOLANA_PROGRAM_ID` in `.env` with the deployed program ID. + +### 6. Setup SSL Certificates + +#### Option A: Let's Encrypt (Recommended) + +```bash +# Obtain certificate +docker-compose -f docker-compose.prod.yml run --rm certbot certonly \ + --webroot -w /var/www/certbot \ + -d coop.hobokenchicken.com \ + --agree-tos --no-eff-email + +# Update nginx config to use Let's Encrypt paths +# Edit docker/nginx/nginx.prod.conf: +# ssl_certificate /etc/letsencrypt/live/coop.hobokenchicken.com/fullchain.pem; +# ssl_certificate_key /etc/letsencrypt/live/coop.hobokenchicken.com/privkey.pem; +``` + +#### Option B: Existing Certificates + +```bash +cp /path/to/your/cert.pem docker/nginx/ssl/cert.pem +cp /path/to/your/key.pem docker/nginx/ssl/key.pem +``` + +#### Option C: Self-Signed (Testing only) + +```bash +openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout docker/nginx/ssl/key.pem \ + -out docker/nginx/ssl/cert.pem \ + -subj '/CN=coop.hobokenchicken.com' +``` + +### 7. Deploy + +```bash +./deployment/deploy-production.sh +``` + +### 8. Configure Tautulli Webhook + +1. Open Tautulli: http://172.20.1.255:8181 +2. Go to **Settings > Notification Agents** +3. Click **Add a new notification agent > Webhook** + +**Configuration:** +- Webhook URL: `https://coop.hobokenchicken.com/webhooks/tautulli` +- Webhook Method: `POST` +- Content Type: `application/json` + +**JSON Payload:** +```json +{ + "action": "watched", + "user_id": "{user_id}", + "username": "{username}", + "rating_key": "{rating_key}", + "session_key": "{session_key}", + "media_type": "{media_type}", + "title": "{title}", + "grandparent_title": "{grandparent_title}", + "started": "{started}", + "stopped": "{stopped}", + "percent_complete": "{percent_complete}", + "is_new": "{is_new}" +} +``` + +**Triggers:** Enable **Watched** + +### 9. Configure Overseer Webhook + +1. Open Overseer: http://172.20.1.225:5055 +2. Go to **Settings > Notifications** +3. Enable **Webhook** + +**Configuration:** +- Webhook URL: `https://coop.hobokenchicken.com/webhooks/overseer` +- Authorization Header: `Bearer your-webhook-secret-from-env` + +**JSON Payload:** +```json +{ + "request_id": "{{request.id}}", + "status": "{{request.status}}", + "media_type": "{{media.media_type}}", + "title": "{{media.title}}" +} +``` + +**Events:** Enable **Request Approved** and **Request Declined** + +## Verification + +### Test Connectivity + +```bash +./deployment/health-check.sh +``` + +### Watch Mode + +```bash +./deployment/health-check.sh --watch +``` + +### Manual Tests + +**Test Tautulli webhook:** +```bash +curl -X POST https://coop.hobokenchicken.com/webhooks/tautulli \ + -H "Content-Type: application/json" \ + -d '{ + "action": "watched", + "user_id": "12345", + "username": "testuser", + "rating_key": "1234", + "session_key": "abc123", + "media_type": "movie", + "title": "Test Movie", + "started": "'$(date +%s)'", + "stopped": "'$(($(date +%s) + 3600))'", + "percent_complete": "90" + }' +``` + +**Test API:** +```bash +curl https://coop.hobokenchicken.com/api/health +``` + +## Firewall Configuration + +If using UFW: + +```bash +# Allow web traffic +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp + +# Allow backend to reach local network +sudo ufw allow from 172.20.2.0/16 to 172.20.1.0/24 + +# Deny direct access to internal services +sudo ufw deny 3000/tcp +sudo ufw deny 3001/tcp +sudo ufw deny 5432/tcp +sudo ufw deny 6379/tcp + +# Enable firewall +sudo ufw enable +``` + +## Troubleshooting + +### Cannot reach local services + +1. Check Docker network: + ```bash + docker network inspect coop-credits_coop-external + ``` + +2. Test connectivity from container: + ```bash + docker exec coop-backend ping 172.20.1.255 + ``` + +3. Verify firewall rules: + ```bash + sudo ufw status verbose + ``` + +### Webhooks not working + +1. Check Nginx logs: + ```bash + tail -f docker/nginx/logs/access.log + ``` + +2. Check backend logs: + ```bash + docker-compose -f docker-compose.prod.yml logs -f backend + ``` + +3. Test webhook manually: + ```bash + curl -X POST https://coop.hobokenchicken.com/webhooks/tautulli \ + -H "Content-Type: application/json" \ + -d '{"test": true}' + ``` + +### Database connection issues + +1. Check database status: + ```bash + docker-compose -f docker-compose.prod.yml ps postgres + ``` + +2. View database logs: + ```bash + docker-compose -f docker-compose.prod.yml logs postgres + ``` + +3. Test connection: + ```bash + docker-compose -f docker-compose.prod.yml exec postgres pg_isready -U coop + ``` + +### SSL certificate issues + +1. Check certificate: + ```bash + openssl s_client -connect coop.hobokenchicken.com:443 -servername coop.hobokenchicken.com + ``` + +2. Verify certificate paths in nginx config + +3. Check certificate expiry: + ```bash + openssl x509 -in docker/nginx/ssl/cert.pem -noout -dates + ``` + +## Maintenance + +### Update Application + +```bash +cd /opt/coop-credits +git pull +./deployment/deploy-production.sh +``` + +### Backup Database + +```bash +# Automated backup +docker-compose -f docker-compose.prod.yml exec -T postgres pg_dump -U coop coop_credits > backup_$(date +%Y%m%d).sql +``` + +### View Logs + +```bash +# All services +docker-compose -f docker-compose.prod.yml logs -f + +# Specific service +docker-compose -f docker-compose.prod.yml logs -f backend +``` + +### Restart Services + +```bash +docker-compose -f docker-compose.prod.yml restart backend +``` + +## Security Checklist + +- [ ] Changed all default passwords in `.env` +- [ ] SSL certificates installed and valid +- [ ] Firewall rules configured +- [ ] Tautulli webhook secret set +- [ ] Overseer webhook secret set +- [ ] JWT secret is random and secure +- [ ] Database not exposed externally +- [ ] Redis password set +- [ ] Encryption key is random and backed up +- [ ] Solana mint authority key backed up securely + +## Support + +For issues: +1. Check health: `./deployment/health-check.sh` +2. Review logs: `docker-compose -f docker-compose.prod.yml logs` +3. Check documentation in `docs/INFRASTRUCTURE.md` diff --git a/frontend/.env.local.example b/frontend/.env.local.example new file mode 100644 index 0000000..400b819 --- /dev/null +++ b/frontend/.env.local.example @@ -0,0 +1,10 @@ +# API +NEXT_PUBLIC_API_URL=http://localhost:3001 + +# Solana +NEXT_PUBLIC_SOLANA_NETWORK=devnet +NEXT_PUBLIC_SOLANA_RPC_URL=https://api.devnet.solana.com + +# App +NEXT_PUBLIC_APP_NAME=CoopCredits +NEXT_PUBLIC_APP_URL=http://localhost:3000 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..8231cb6 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,38 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Build Next.js app +RUN npm run build + +# Production stage +FROM node:20-alpine + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install production dependencies only +RUN npm ci --only=production + +# Copy built files from builder +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/public ./public +COPY --from=builder /app/next.config.js ./ + +# Expose port +EXPOSE 3000 + +# Start application +CMD ["npm", "start"] diff --git a/frontend/next.config.js b/frontend/next.config.js new file mode 100644 index 0000000..888b75d --- /dev/null +++ b/frontend/next.config.js @@ -0,0 +1,20 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + experimental: { + appDir: true, + }, + async rewrites() { + return [ + { + source: '/api/:path*', + destination: `${process.env.NEXT_PUBLIC_API_URL}/api/:path*`, + }, + { + source: '/webhooks/:path*', + destination: `${process.env.NEXT_PUBLIC_API_URL}/webhooks/:path*`, + }, + ]; + }, +}; + +module.exports = nextConfig; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..8c2fd66 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,44 @@ +{ + "name": "coop-credits-frontend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@solana/wallet-adapter-base": "^0.9.23", + "@solana/wallet-adapter-react": "^0.15.35", + "@solana/wallet-adapter-react-ui": "^0.9.34", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.0.4", + "@solana/wallet-adapter-wallets": "^0.19.32", + "@solana/web3.js": "^1.87.6", + "axios": "^1.6.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.0.0", + "lucide-react": "^0.294.0", + "next": "14.0.4", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "recharts": "^2.10.3", + "socket.io-client": "^4.7.3", + "sonner": "^1.2.4", + "tailwind-merge": "^2.2.0", + "tailwindcss-animate": "^1.0.7", + "zustand": "^4.4.7" + }, + "devDependencies": { + "@types/node": "^20.10.5", + "@types/react": "^18.2.45", + "@types/react-dom": "^18.2.18", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.4.0", + "typescript": "^5.3.3" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx new file mode 100644 index 0000000..951df95 --- /dev/null +++ b/frontend/src/app/admin/page.tsx @@ -0,0 +1,333 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useStore } from '@/lib/store'; +import { adminApi } from '@/lib/api'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Users, + Settings, + Pause, + Play, + TrendingUp, + Search, + Gift +} from 'lucide-react'; +import { formatNumber } from '@/lib/utils'; +import { toast } from 'sonner'; + +interface Analytics { + users: { + total: number; + with_wallet: number; + new_this_week: number; + }; + transactions: { + total_earned: number; + total_spent: number; + total_transactions: number; + }; + watchStats: { + total_seconds: number; + total_credits: number; + total_events: number; + }; +} + +interface User { + id: string; + plexUsername: string; + email: string | null; + isAdmin: boolean; + isActive: boolean; + walletAddress: string | null; + totalEarned: number; + totalSpent: number; + watchTimeMinutes: number; + createdAt: string; +} + +export default function AdminPage() { + const router = useRouter(); + const { user, isAuthenticated } = useStore(); + const [analytics, setAnalytics] = useState(null); + const [users, setUsers] = useState([]); + const [settings, setSettings] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + + useEffect(() => { + if (!isAuthenticated) { + router.push('/login'); + return; + } + + if (!user?.isAdmin) { + router.push('/dashboard'); + return; + } + + loadData(); + }, [isAuthenticated, user, router]); + + const loadData = async () => { + try { + const [analyticsRes, usersRes, settingsRes] = await Promise.all([ + adminApi.getAnalytics(), + adminApi.getUsers(), + adminApi.getSettings() + ]); + + setAnalytics(analyticsRes.data); + setUsers(usersRes.data.users); + setSettings(settingsRes.data); + } catch (error) { + toast.error('Failed to load admin data'); + } finally { + setIsLoading(false); + } + }; + + const handlePause = async () => { + try { + await adminApi.pause(); + toast.success('Minting paused'); + loadData(); + } catch (error) { + toast.error('Failed to pause minting'); + } + }; + + const handleResume = async () => { + try { + await adminApi.resume(); + toast.success('Minting resumed'); + loadData(); + } catch (error) { + toast.error('Failed to resume minting'); + } + }; + + const handleGrantBonus = async (userId: string) => { + const amount = prompt('Enter bonus amount:'); + if (!amount) return; + + try { + await adminApi.grantBonus(userId, parseInt(amount), 'Admin bonus'); + toast.success('Bonus granted'); + loadData(); + } catch (error) { + toast.error('Failed to grant bonus'); + } + }; + + if (!isAuthenticated || !user?.isAdmin) { + return null; + } + + if (isLoading) { + return ( +
+

Loading...

+
+ ); + } + + return ( +
+
+
+
+

Admin Dashboard

+ {user.plexUsername} +
+ +
+
+ +
+ {/* Stats Overview */} +
+ + + Total Users + + + +
{analytics?.users.total || 0}
+

+ {analytics?.users.with_wallet || 0} with wallets +

+
+
+ + + + Total Minted + + + +
+ {formatNumber(analytics?.transactions.total_earned || 0)} $COOP +
+

+ {analytics?.transactions.total_transactions || 0} transactions +

+
+
+ + + + Watch Time + + + +
+ {Math.floor((analytics?.watchStats.total_seconds || 0) / 3600)}h +
+

+ {analytics?.watchStats.total_events || 0} watch events +

+
+
+ + + + System Status + + + +
+
+ + {settings?.mintingPaused ? 'Paused' : 'Active'} + +
+
+ + +
+ + +
+ + {/* Tabs */} + + + Users + Settings + + + +
+
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+
+ + + +
+ {users.map((u) => ( +
+
+

{u.plexUsername}

+

{u.email}

+
+ {u.isAdmin && Admin} + {!u.isActive && Inactive} + {u.walletAddress && Wallet} +
+
+
+

+ {formatNumber(u.totalEarned - u.totalSpent)} $COOP +

+ +
+
+ ))} +
+
+
+
+ + + + + Minting Settings + Configure how users earn $COOP + + +
+
+ + setSettings({ ...settings, creditsPerMinute: parseInt(e.target.value) })} + /> +
+
+ + setSettings({ ...settings, minWatchPercent: parseInt(e.target.value) })} + /> +
+
+ + setSettings({ ...settings, movieRequestCost: parseInt(e.target.value) })} + /> +
+
+ + setSettings({ ...settings, tvRequestCost: parseInt(e.target.value) })} + /> +
+
+ +
+
+
+
+
+
+ ); +} diff --git a/frontend/src/app/dashboard/components/CreateWalletModal.tsx b/frontend/src/app/dashboard/components/CreateWalletModal.tsx new file mode 100644 index 0000000..21bd963 --- /dev/null +++ b/frontend/src/app/dashboard/components/CreateWalletModal.tsx @@ -0,0 +1,162 @@ +'use client'; + +import { useState } from 'react'; +import { walletApi } from '@/lib/api'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Copy, Check, AlertTriangle, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; + +interface CreateWalletModalProps { + open: boolean; + onClose: () => void; + onCreated: () => void; +} + +export function CreateWalletModal({ open, onClose, onCreated }: CreateWalletModalProps) { + const [step, setStep] = useState<'create' | 'backup' | 'success'>('create'); + const [privateKey, setPrivateKey] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [copied, setCopied] = useState(false); + + const handleCreate = async () => { + setIsLoading(true); + try { + const response = await walletApi.createWallet(); + setPrivateKey(response.data.privateKey || ''); + setStep('backup'); + toast.success('Wallet created successfully!'); + onCreated(); + } catch (error) { + toast.error('Failed to create wallet'); + } finally { + setIsLoading(false); + } + }; + + const handleBackup = async () => { + try { + const response = await walletApi.backupWallet(); + setPrivateKey(response.data.privateKey); + setStep('backup'); + } catch (error) { + toast.error('Failed to get backup'); + } + }; + + const copyToClipboard = () => { + navigator.clipboard.writeText(privateKey); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + toast.success('Copied to clipboard'); + }; + + const handleClose = () => { + setStep('create'); + setPrivateKey(''); + setCopied(false); + onClose(); + }; + + return ( + + + + + {step === 'create' && 'Create Wallet'} + {step === 'backup' && 'Backup Your Wallet'} + {step === 'success' && 'Wallet Ready!'} + + + {step === 'create' && 'Create a new Solana wallet to store your $COOP tokens'} + {step === 'backup' && 'Save this private key securely. You will need it to recover your wallet.'} + {step === 'success' && 'Your wallet is ready to use!'} + + + + {step === 'create' && ( +
+ + + + You will be shown a private key. Store it securely - it cannot be recovered! + + + +
+ or +
+ +
+ )} + + {step === 'backup' && ( +
+ + + + Never share this private key with anyone. Store it in a secure password manager. + + +
+ +
+ + +
+
+ +
+ )} + + {step === 'success' && ( +
+
+ +
+

+ Your wallet has been created and funded with 2 SOL for transaction fees. + Start watching content on Plex to earn $COOP! +

+ +
+ )} +
+
+ ); +} diff --git a/frontend/src/app/dashboard/components/TransactionList.tsx b/frontend/src/app/dashboard/components/TransactionList.tsx new file mode 100644 index 0000000..9291966 --- /dev/null +++ b/frontend/src/app/dashboard/components/TransactionList.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { transactionApi } from '@/lib/api'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { formatNumber, formatDate } from '@/lib/utils'; +import { TrendingUp, TrendingDown, Gift, ArrowRightLeft } from 'lucide-react'; + +interface Transaction { + id: string; + type: 'EARN' | 'SPEND' | 'BONUS' | 'ADJUSTMENT'; + amount: number; + description: string | null; + contentTitle: string | null; + createdAt: string; + solanaSignature: string | null; +} + +export function TransactionList() { + const [transactions, setTransactions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + loadTransactions(); + }, []); + + const loadTransactions = async () => { + try { + const response = await transactionApi.getTransactions(); + setTransactions(response.data.transactions); + } catch (error) { + console.error('Failed to load transactions:', error); + } finally { + setIsLoading(false); + } + }; + + const getTransactionIcon = (type: string) => { + switch (type) { + case 'EARN': + return ; + case 'SPEND': + return ; + case 'BONUS': + return ; + default: + return ; + } + }; + + const getTransactionColor = (type: string) => { + switch (type) { + case 'EARN': + return 'bg-green-500/10 text-green-500'; + case 'SPEND': + return 'bg-red-500/10 text-red-500'; + case 'BONUS': + return 'bg-purple-500/10 text-purple-500'; + default: + return 'bg-gray-500/10 text-gray-500'; + } + }; + + if (isLoading) { + return ( + + + Loading transactions... + + + ); + } + + if (transactions.length === 0) { + return ( + + + No transactions yet. Start watching content to earn $COOP! + + + ); + } + + return ( + + + Recent Transactions + + +
+ {transactions.map((tx) => ( +
+
+
+ {getTransactionIcon(tx.type)} +
+
+

+ {tx.contentTitle || tx.description || tx.type} +

+

+ {formatDate(tx.createdAt)} +

+ {tx.solanaSignature && ( + + View on Explorer + + )} +
+
+
+

+ {tx.type === 'EARN' || tx.type === 'BONUS' ? '+' : '-'} + {formatNumber(tx.amount)} $COOP +

+ + {tx.type} + +
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/app/dashboard/components/WatchHistory.tsx b/frontend/src/app/dashboard/components/WatchHistory.tsx new file mode 100644 index 0000000..3bf1910 --- /dev/null +++ b/frontend/src/app/dashboard/components/WatchHistory.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { userApi } from '@/lib/api'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { formatDuration, formatDate, formatNumber } from '@/lib/utils'; +import { Film, Tv, CheckCircle, XCircle, Clock } from 'lucide-react'; + +interface WatchEvent { + id: string; + contentType: string; + title: string; + grandparentTitle: string | null; + duration: number; + percentComplete: number; + creditsEarned: number; + isProcessed: boolean; + watchedAt: string; +} + +export function WatchHistory() { + const [events, setEvents] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + loadHistory(); + }, []); + + const loadHistory = async () => { + try { + const response = await userApi.getWatchHistory(); + setEvents(response.data.events); + } catch (error) { + console.error('Failed to load watch history:', error); + } finally { + setIsLoading(false); + } + }; + + const getContentIcon = (type: string) => { + return type === 'movie' ? ( + + ) : ( + + ); + }; + + if (isLoading) { + return ( + + + Loading watch history... + + + ); + } + + if (events.length === 0) { + return ( + + + No watch history yet. Start watching on Plex! + + + ); + } + + return ( + + + Watch History + + +
+ {events.map((event) => ( +
+
+
+ {getContentIcon(event.contentType)} +
+
+

{event.title}

+ {event.grandparentTitle && ( +

+ {event.grandparentTitle} +

+ )} +
+ + + {formatDuration(event.duration)} + + {event.percentComplete}% watched + {formatDate(event.watchedAt)} +
+
+
+
+ {event.isProcessed ? ( +
+ + + +{formatNumber(event.creditsEarned)} $COOP + +
+ ) : ( +
+ + Pending +
+ )} +
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx new file mode 100644 index 0000000..ae4f0d9 --- /dev/null +++ b/frontend/src/app/dashboard/page.tsx @@ -0,0 +1,232 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useStore } from '@/lib/store'; +import { walletApi, transactionApi, overseerApi } from '@/lib/api'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Wallet, + TrendingUp, + TrendingDown, + Clock, + Film, + ExternalLink, + Plus, + History +} from 'lucide-react'; +import { formatNumber, truncateAddress } from '@/lib/utils'; +import { toast } from 'sonner'; +import { TransactionList } from './components/TransactionList'; +import { WatchHistory } from './components/WatchHistory'; +import { CreateWalletModal } from './components/CreateWalletModal'; + +interface WalletData { + hasWallet: boolean; + address?: string; + balance: number; + totalEarned: number; + totalSpent: number; + explorerUrl?: string; +} + +export default function DashboardPage() { + const router = useRouter(); + const { user, isAuthenticated, logout } = useStore(); + const [wallet, setWallet] = useState(null); + const [requestCosts, setRequestCosts] = useState({ movie: 100, tv: 200 }); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (!isAuthenticated) { + router.push('/login'); + return; + } + + loadData(); + }, [isAuthenticated, router]); + + const loadData = async () => { + try { + const [walletRes, costsRes] = await Promise.all([ + walletApi.getWallet(), + overseerApi.getCosts().catch(() => ({ data: { movie: 100, tv: 200 } })) + ]); + + setWallet(walletRes.data); + setRequestCosts(costsRes.data); + } catch (error) { + toast.error('Failed to load wallet data'); + } finally { + setIsLoading(false); + } + }; + + if (!isAuthenticated || !user) { + return null; + } + + return ( +
+ {/* Header */} +
+
+
+

CoopCredits

+ {user.plexUsername} + {user.isAdmin && ( + + )} +
+ +
+
+ +
+ {/* Stats Cards */} +
+ + + Balance + + + +
+ {wallet ? formatNumber(wallet.balance) : '--'} $COOP +
+

+ {wallet?.hasWallet ? 'Ready to spend' : 'Create wallet to start'} +

+
+
+ + + + Total Earned + + + +
+ {wallet ? formatNumber(wallet.totalEarned) : '--'} $COOP +
+

+ From watching content +

+
+
+ + + + Total Spent + + + +
+ {wallet ? formatNumber(wallet.totalSpent) : '--'} $COOP +
+

+ On content requests +

+
+
+ + + + Request Cost + + + +
{requestCosts.movie} $COOP
+

+ Per movie request +

+
+
+
+ + {/* Wallet Section */} + {!wallet?.hasWallet && ( + + + +

No Wallet Connected

+

+ Create a Solana wallet to start earning and spending $COOP tokens +

+ +
+
+ )} + + {wallet?.hasWallet && ( + + + + + Your Wallet + + + Manage your Solana wallet and $COOP tokens + + + +
+
+

Address

+

+ {truncateAddress(wallet.address!)} +

+
+
+ + +
+
+
+
+ )} + + {/* Tabs */} + + + Transactions + Watch History + + + + + + + + + + +
+ + setIsCreateModalOpen(false)} + onCreated={loadData} + /> +
+ ); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 0000000..00b08e3 --- /dev/null +++ b/frontend/src/app/globals.css @@ -0,0 +1,59 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 221.2 83.2% 53.3%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 221.2 83.2% 53.3%; + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 217.2 91.2% 59.8%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 224.3 76.3% 48%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000..0f1b2b5 --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -0,0 +1,29 @@ +import type { Metadata } from 'next'; +import { Inter } from 'next/font/google'; +import './globals.css'; +import { Providers } from '@/components/providers'; +import { Toaster } from 'sonner'; + +const inter = Inter({ subsets: ['latin'] }); + +export const metadata: Metadata = { + title: 'CoopCredits - Media Rewards', + description: 'Earn $COOP tokens for watching content on Plex', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + {children} + + + + + ); +} diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx new file mode 100644 index 0000000..9b16d62 --- /dev/null +++ b/frontend/src/app/login/page.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { authApi } from '@/lib/api'; +import { useStore } from '@/lib/store'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Loader2, Tv } from 'lucide-react'; +import { toast } from 'sonner'; + +export default function LoginPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { setUser, setToken, isAuthenticated } = useStore(); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + // Check for Plex OAuth callback + const code = searchParams.get('code'); + if (code) { + handlePlexCallback(code); + } + }, [searchParams]); + + useEffect(() => { + if (isAuthenticated) { + router.push('/dashboard'); + } + }, [isAuthenticated, router]); + + const handlePlexCallback = async (code: string) => { + setIsLoading(true); + try { + const response = await authApi.plexCallback(code); + const { user, token } = response.data; + + localStorage.setItem('token', token); + setUser(user); + setToken(token); + + toast.success(`Welcome, ${user.plexUsername}!`); + router.push('/dashboard'); + } catch (error) { + toast.error('Authentication failed'); + console.error(error); + } finally { + setIsLoading(false); + } + }; + + const handlePlexLogin = async () => { + setIsLoading(true); + try { + const response = await authApi.getPlexUrl(); + window.location.href = response.data.authUrl; + } catch (error) { + toast.error('Failed to initiate Plex login'); + console.error(error); + setIsLoading(false); + } + }; + + return ( +
+ + +
+ +
+ CoopCredits + + Earn $COOP tokens for watching content on Plex + +
+ + +

+ Sign in with your Plex account to start earning rewards +

+
+
+
+ ); +} diff --git a/frontend/src/components/providers.tsx b/frontend/src/components/providers.tsx new file mode 100644 index 0000000..596770b --- /dev/null +++ b/frontend/src/components/providers.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { ReactNode, useEffect, useState } from 'react'; +import { ThemeProvider } from 'next-themes'; +import { useStore } from '@/lib/store'; +import { authApi } from '@/lib/api'; + +export function Providers({ children }: { children: ReactNode }) { + const [mounted, setMounted] = useState(false); + const { setUser, setToken } = useStore(); + + useEffect(() => { + setMounted(true); + + // Check for stored token on mount + const token = localStorage.getItem('token'); + if (token) { + // Verify token and get user data + authApi.verify() + .then((res) => { + setUser(res.data.user); + setToken(token); + }) + .catch(() => { + localStorage.removeItem('token'); + }); + } + }, [setUser, setToken]); + + if (!mounted) { + return <>{children}; + } + + return ( + + {children} + + ); +} diff --git a/frontend/src/components/ui/alert.tsx b/frontend/src/components/ui/alert.tsx new file mode 100644 index 0000000..6469bcd --- /dev/null +++ b/frontend/src/components/ui/alert.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; + +const alertVariants = cva( + 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground', + { + variants: { + variant: { + default: 'bg-background text-foreground', + destructive: + 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = 'Alert'; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = 'AlertTitle'; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = 'AlertDescription'; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..803e29e --- /dev/null +++ b/frontend/src/components/ui/badge.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + variant: { + default: + 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', + secondary: + 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: + 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..b4d045e --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: + 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + outline: + 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', + secondary: + 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-10 px-4 py-2', + sm: 'h-9 rounded-md px-3', + lg: 'h-11 rounded-md px-8', + icon: 'h-10 w-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button'; + return ( + + ); + } +); +Button.displayName = 'Button'; + +export { Button, buttonVariants }; diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..81bd82d --- /dev/null +++ b/frontend/src/components/ui/card.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = 'Card'; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = 'CardHeader'; + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardTitle.displayName = 'CardTitle'; + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardDescription.displayName = 'CardDescription'; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardContent.displayName = 'CardContent'; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = 'CardFooter'; + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..29110c3 --- /dev/null +++ b/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,121 @@ +'use client'; + +import * as React from 'react'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { X } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = 'DialogHeader'; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = 'DialogFooter'; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..b651d6b --- /dev/null +++ b/frontend/src/components/ui/input.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export interface InputProps + extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ); + } +); +Input.displayName = 'Input'; + +export { Input }; diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx new file mode 100644 index 0000000..9bb4934 --- /dev/null +++ b/frontend/src/components/ui/label.tsx @@ -0,0 +1,25 @@ +'use client'; + +import * as React from 'react'; +import * as LabelPrimitive from '@radix-ui/react-label'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; + +const labelVariants = cva( + 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70' +); + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx new file mode 100644 index 0000000..87f3fa3 --- /dev/null +++ b/frontend/src/components/ui/tabs.tsx @@ -0,0 +1,54 @@ +'use client'; + +import * as React from 'react'; +import * as TabsPrimitive from '@radix-ui/react-tabs'; +import { cn } from '@/lib/utils'; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..3a68ca3 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,98 @@ +import axios from 'axios'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +export const api = axios.create({ + baseURL: `${API_URL}/api`, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Add auth token to requests +api.interceptors.request.use((config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// Handle token expiration +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + window.location.href = '/login'; + } + return Promise.reject(error); + } +); + +// Auth API +export const authApi = { + getPlexUrl: () => api.get('/auth/plex/url'), + plexCallback: (code: string) => api.post('/auth/plex/callback', { code }), + verify: () => api.get('/auth/verify'), + logout: () => api.post('/auth/logout'), +}; + +// User API +export const userApi = { + getMe: () => api.get('/users/me'), + updateMe: (data: { email?: string }) => api.put('/users/me', data), + getWatchHistory: (page = 1, limit = 20) => + api.get(`/users/me/watch-history?page=${page}&limit=${limit}`), + getRequests: (page = 1, limit = 20, status?: string) => + api.get(`/users/me/requests?page=${page}&limit=${limit}${status ? `&status=${status}` : ''}`), +}; + +// Wallet API +export const walletApi = { + getWallet: () => api.get('/wallet'), + createWallet: () => api.post('/wallet/create'), + connectWallet: (address: string) => api.post('/wallet/connect', { address }), + backupWallet: () => api.post('/wallet/backup'), +}; + +// Transactions API +export const transactionApi = { + getTransactions: (page = 1, limit = 20, type?: string) => + api.get(`/transactions?page=${page}&limit=${limit}${type ? `&type=${type}` : ''}`), + getStats: () => api.get('/transactions/stats'), +}; + +// Admin API +export const adminApi = { + getSettings: () => api.get('/admin/settings'), + updateSettings: (data: any) => api.put('/admin/settings', data), + pause: () => api.post('/admin/pause'), + resume: () => api.post('/admin/resume'), + getUsers: (page = 1, limit = 50, search?: string) => + api.get(`/admin/users?page=${page}&limit=${limit}${search ? `&search=${search}` : ''}`), + updateUser: (id: string, data: any) => api.put(`/admin/users/${id}`, data), + grantBonus: (userId: string, amount: number, reason?: string) => + api.post(`/admin/users/${userId}/bonus`, { amount, reason }), + getAnalytics: () => api.get('/admin/analytics'), + getAllTransactions: (page = 1, limit = 50) => + api.get(`/transactions/admin/all?page=${page}&limit=${limit}`), + getSystemStats: () => api.get('/transactions/admin/stats'), +}; + +// Overseer API +export const overseerApi = { + getCosts: () => api.get('/overseer/costs'), + getBalance: () => api.get('/overseer/balance'), + search: (query: string) => api.get(`/overseer/search?query=${encodeURIComponent(query)}`), + request: (data: { mediaType: string; mediaId: number; title: string; seasons?: number[] }) => + api.post('/overseer/request', data), +}; + +// Tautulli API +export const tautulliApi = { + getStatus: () => api.get('/tautulli/status'), + getStats: () => api.get('/tautulli/stats'), + getWebhookConfig: () => api.get('/tautulli/webhook-config'), +}; diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts new file mode 100644 index 0000000..5339e3e --- /dev/null +++ b/frontend/src/lib/store.ts @@ -0,0 +1,42 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +export interface User { + id: string; + plexId: string; + plexUsername: string; + email: string | null; + isAdmin: boolean; + walletAddress: string | null; + totalEarned: number; + totalSpent: number; +} + +interface AppState { + user: User | null; + token: string | null; + isAuthenticated: boolean; + setUser: (user: User | null) => void; + setToken: (token: string | null) => void; + logout: () => void; +} + +export const useStore = create()( + persist( + (set) => ({ + user: null, + token: null, + isAuthenticated: false, + setUser: (user) => set({ user, isAuthenticated: !!user }), + setToken: (token) => set({ token }), + logout: () => { + localStorage.removeItem('token'); + set({ user: null, token: null, isAuthenticated: false }); + }, + }), + { + name: 'coop-credits-storage', + partialize: (state) => ({ user: state.user, token: state.token }), + } + ) +); diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..0595fa4 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,35 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +export function formatNumber(num: number): string { + return new Intl.NumberFormat('en-US').format(num); +} + +export function formatDate(date: string | Date): string { + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(date)); +} + +export function formatDuration(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + return `${minutes}m`; +} + +export function truncateAddress(address: string, chars = 4): string { + if (!address) return ''; + return `${address.slice(0, chars)}...${address.slice(-chars)}`; +} diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..6b8c941 --- /dev/null +++ b/frontend/tailwind.config.ts @@ -0,0 +1,57 @@ +import type { Config } from 'tailwindcss'; + +const config: Config = { + darkMode: ['class'], + content: [ + './pages/**/*.{js,ts,jsx,tsx,mdx}', + './components/**/*.{js,ts,jsx,tsx,mdx}', + './app/**/*.{js,ts,jsx,tsx,mdx}', + ], + theme: { + extend: { + colors: { + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))', + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + }, + }, + plugins: [require('tailwindcss-animate')], +}; + +export default config; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..5ddf5a5 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e3549fe --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "coop-credits", + "version": "1.0.0", + "description": "CoopCredits - Solana-based media rewards ecosystem", + "private": true, + "scripts": { + "dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"", + "dev:backend": "cd backend && npm run dev", + "dev:frontend": "cd frontend && npm run dev", + "install:all": "npm install && cd backend && npm install && cd ../frontend && npm install && cd ../anchor-program && npm install", + "build": "cd backend && npm run build && cd ../frontend && npm run build", + "db:migrate": "cd backend && npx prisma migrate dev", + "db:generate": "cd backend && npx prisma generate", + "db:studio": "cd backend && npx prisma studio", + "docker:up": "docker-compose up -d", + "docker:down": "docker-compose down", + "docker:logs": "docker-compose logs -f", + "docker:prod:up": "docker-compose -f docker-compose.prod.yml up -d", + "docker:prod:down": "docker-compose -f docker-compose.prod.yml down", + "docker:prod:logs": "docker-compose -f docker-compose.prod.yml logs -f", + "deploy": "./deployment/deploy.sh", + "deploy:prod": "./deployment/deploy-production.sh", + "setup:solana": "./deployment/setup-solana.sh", + "setup:infra": "./deployment/setup-infrastructure.sh", + "health": "./deployment/health-check.sh", + "lint": "cd backend && npm run lint && cd ../frontend && npm run lint" + }, + "devDependencies": { + "concurrently": "^8.2.2" + }, + "workspaces": [ + "backend", + "frontend", + "anchor-program" + ] +}