feat: add CoopCredits Solana media rewards ecosystem

Add complete  token system with Plex/Tautulli/Overseer integration:
- Anchor program for SPL token mint/burn/transfer
- Express backend with OAuth, webhooks, Solana integration
- Next.js frontend with dashboard, admin panel, wallet management
- Docker deployment for 172.20.1.0/24 infrastructure
- Production configs with SSL, Nginx, health monitoring

Tautulli webhooks auto-mint  on watch events.
Overseer integration burns  for content requests.
This commit is contained in:
2026-04-14 11:09:50 -04:00
parent f106328f6a
commit e8d9b1fd42
69 changed files with 11382 additions and 0 deletions
+87
View File
@@ -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);
}
+220
View File
@@ -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<PublicKey | null> {
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<PublicKey> {
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<string | null> {
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<string | null> {
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<number> {
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<string | null> {
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;
}
}