chore: remove solana wallet stack

This commit is contained in:
2026-04-22 13:32:14 -04:00
parent 8ec91acc25
commit 4bfcbb6b7e
8 changed files with 104 additions and 744 deletions
+19 -76
View File
@@ -9,33 +9,25 @@ datasource db {
}
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[]
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")
totalEarned Int @default(0) @map("total_earned")
totalSpent Int @default(0) @map("total_spent")
watchTimeMinutes Int @default(0) @map("watch_time_minutes")
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")
}
@@ -43,29 +35,16 @@ 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? @unique @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")
}
@@ -74,28 +53,18 @@ 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
contentType String @map("content_type")
title String
grandparentTitle String? @map("grandparent_title") // Show name for episodes
// Watch stats
duration Int // seconds watched
grandparentTitle String? @map("grandparent_title")
duration Int
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])
@@ -106,30 +75,17 @@ 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
mediaType String @map("media_type")
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")
}
@@ -137,12 +93,9 @@ 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")
@@ -150,28 +103,18 @@ model Session {
model SystemSettings {
id String @id @default(cuid())
// Minting settings
creditsPerMinute Int @default(2) @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(500) @map("movie_request_cost")
tvRequestCost Int @default(1000) @map("tv_request_cost")
tvPerSeasonCost Int @default(250) @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")
}
+28 -155
View File
@@ -3,7 +3,6 @@ import crypto from "crypto";
import { Router } from "express";
import jwt from "jsonwebtoken";
import { asyncHandler } from "../middleware/errorHandler";
import { createWallet } from "../services/solana";
import { prisma } from "../utils/prisma";
const ENCRYPTION_KEY =
@@ -27,18 +26,11 @@ 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";
if (!PLEX_CLIENT_ID) {
console.error("ERROR: PLEX_CLIENT_ID not set");
}
// Step 1: Generate Plex PIN and return auth URL
router.get(
"/plex/url",
asyncHandler(async (_req, res) => {
if (!PLEX_CLIENT_ID) {
if (!PLEX_CLIENT_ID)
return res.status(500).json({ error: "Plex not configured" });
}
const pinResponse = await axios.post(
"https://plex.tv/api/v2/pins?strong=true",
null,
@@ -53,25 +45,18 @@ router.get(
},
},
);
const pin = pinResponse.data;
const authUrl = `https://app.plex.tv/auth#?clientID=${encodeURIComponent(PLEX_CLIENT_ID)}&code=${pin.code}&forwardUrl=${encodeURIComponent(PLEX_REDIRECT_URI)}`;
res.json({ authUrl, pinId: pin.id });
}),
);
// Step 2: Exchange PIN for Plex token and create user session
router.post(
"/plex/callback",
asyncHandler(async (req, res) => {
const { pinId } = req.body;
if (!pinId) return res.status(400).json({ error: "PIN ID required" });
if (!pinId) {
return res.status(400).json({ error: "PIN ID required" });
}
// Check PIN status to get auth token
const pinResponse = await axios.get(
`https://plex.tv/api/v2/pins/${pinId}`,
{
@@ -81,33 +66,23 @@ router.post(
},
},
);
const pin = pinResponse.data;
if (!pin.authToken) {
if (!pin.authToken)
return res.status(400).json({ error: "Authentication not completed" });
}
const plexToken = pin.authToken;
// Get user info from Plex
const userResponse = await axios.get("https://plex.tv/api/v2/user", {
headers: {
"X-Plex-Token": plexToken,
"X-Plex-Token": pin.authToken,
"X-Plex-Client-Identifier": PLEX_CLIENT_ID,
Accept: "application/json",
},
});
const plexUser = userResponse.data;
// Find or create user
let user = await prisma.user.findUnique({
where: { plexId: String(plexUser.id) },
});
const isNewUser = !user;
if (!user) {
user = await prisma.user.create({
data: {
@@ -127,7 +102,6 @@ router.post(
});
}
// Delete old sessions and create new one
await prisma.session.deleteMany({ where: { userId: user.id } });
const sessionToken = jwt.sign(
{ userId: user.id, nonce: Date.now() },
@@ -142,22 +116,13 @@ router.post(
},
});
// Backfill 30 days of watch history for new users
if (isNewUser) {
await backfillUserHistory(user);
}
if (isNewUser) await backfillUserHistory(user);
// Generate JWT
const token = jwt.sign(
{
userId: user.id,
plexId: user.plexId,
isAdmin: user.isAdmin,
},
{ userId: user.id, plexId: user.plexId, isAdmin: user.isAdmin },
JWT_SECRET,
{ expiresIn: "7d" },
);
res.json({
token,
sessionToken: session.token,
@@ -167,7 +132,6 @@ router.post(
plexUsername: user.plexUsername,
email: user.email,
isAdmin: user.isAdmin,
walletAddress: user.walletAddress,
totalEarned: user.totalEarned,
totalSpent: user.totalSpent,
},
@@ -179,24 +143,15 @@ router.get(
"/verify",
asyncHandler(async (req, res) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
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 decoded = jwt.verify(authHeader.substring(7), JWT_SECRET) as any;
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
});
if (!user || !user.isActive) {
if (!user || !user.isActive)
return res.status(401).json({ error: "User not found or inactive" });
}
res.json({
user: {
id: user.id,
@@ -204,12 +159,11 @@ router.get(
plexUsername: user.plexUsername,
email: user.email,
isAdmin: user.isAdmin,
walletAddress: user.walletAddress,
totalEarned: user.totalEarned,
totalSpent: user.totalSpent,
},
});
} catch (error) {
} catch {
res.status(401).json({ error: "Invalid token" });
}
}),
@@ -219,21 +173,12 @@ 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 {
// Ignore invalid tokens on logout
}
const decoded = jwt.verify(authHeader.substring(7), JWT_SECRET) as any;
await prisma.session.deleteMany({ where: { userId: decoded.userId } });
} catch {}
}
res.json({ message: "Logged out successfully" });
}),
);
@@ -242,38 +187,12 @@ async function backfillUserHistory(user: {
id: string;
plexId: string;
plexUsername: string;
walletAddress: string | null;
}) {
if (!TAUTULLI_URL || !TAUTULLI_API_KEY) {
console.log("Tautulli not configured, skipping backfill");
return;
}
if (!TAUTULLI_URL || !TAUTULLI_API_KEY) return;
try {
// Create wallet if user doesn't have one
if (!user.walletAddress) {
const wallet = createWallet();
await prisma.user.update({
where: { id: user.id },
data: {
walletAddress: wallet.publicKey,
encryptedPrivateKey: encrypt(wallet.secretKey),
},
});
user.walletAddress = wallet.publicKey;
console.log(
`Created wallet for new user ${user.plexUsername}: ${wallet.publicKey}`,
);
}
// Map Plex username to Tautulli user ID
const usersResponse = await axios.get(`${TAUTULLI_URL}/api/v2`, {
params: {
apikey: TAUTULLI_API_KEY,
cmd: "get_users",
},
params: { apikey: TAUTULLI_API_KEY, cmd: "get_users" },
});
const tautulliUsers = usersResponse.data?.response?.data || [];
const tautulliUser = tautulliUsers.find(
(u: any) =>
@@ -281,44 +200,25 @@ async function backfillUserHistory(user: {
u.email?.toLowerCase() === user.plexUsername.toLowerCase() ||
u.friendly_name?.toLowerCase() === user.plexUsername.toLowerCase(),
);
if (!tautulliUser) return;
if (!tautulliUser) {
console.log(
`Tautulli user not found for Plex username ${user.plexUsername}, skipping backfill`,
);
return;
}
const tautulliUserId = tautulliUser.user_id;
console.log(
`Mapped ${user.plexUsername} to Tautulli user ID ${tautulliUserId}`,
);
// Calculate 30 days ago
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const startDate = thirtyDaysAgo.toISOString().split("T")[0];
// Fetch history from Tautulli
const startDate = (() => {
const d = new Date();
d.setDate(d.getDate() - 30);
return d.toISOString().split("T")[0];
})();
const historyResponse = await axios.get(`${TAUTULLI_URL}/api/v2`, {
params: {
apikey: TAUTULLI_API_KEY,
cmd: "get_history",
user_id: tautulliUserId,
user_id: tautulliUser.user_id,
start_date: startDate,
length: 1000,
},
});
const historyData = historyResponse.data?.response?.data?.data || [];
if (!Array.isArray(historyData) || historyData.length === 0) {
console.log(
`No watch history found for user ${user.plexUsername} in last 30 days`,
);
return;
}
if (!Array.isArray(historyData) || historyData.length === 0) return;
// Get system settings for credit calculation
const settings = await prisma.systemSettings.findFirst();
const creditsPerMinute = settings?.creditsPerMinute || 2;
const minWatchPercent = settings?.minWatchPercent || 80;
@@ -326,38 +226,21 @@ async function backfillUserHistory(user: {
let totalCredits = 0;
let totalMinutes = 0;
let processedCount = 0;
for (const item of historyData) {
const sessionId = item.reference_id?.toString() || item.id?.toString();
if (!sessionId) continue;
// Skip duplicates
const existing = await prisma.watchEvent.findUnique({
where: { sessionId },
});
if (existing) continue;
if (await prisma.watchEvent.findUnique({ where: { sessionId } }))
continue;
const percentComplete = item.percent_complete || 0;
const watchDurationMinutes = Math.floor(
(item.stopped - item.started) / 60,
);
if (
percentComplete < minWatchPercent ||
watchDurationMinutes < minWatchMinutes
) {
)
continue;
}
let creditsEarned = watchDurationMinutes * creditsPerMinute;
if (settings?.newReleaseMultiplier && item.is_new) {
creditsEarned = Math.floor(
creditsEarned * Number(settings.newReleaseMultiplier),
);
}
// Create watch event
const creditsEarned = watchDurationMinutes * creditsPerMinute;
const watchEvent = await prisma.watchEvent.create({
data: {
userId: user.id,
@@ -373,8 +256,6 @@ async function backfillUserHistory(user: {
watchedAt: new Date(item.stopped * 1000),
},
});
// DB-only credit record
await prisma.transaction.create({
data: {
userId: user.id,
@@ -385,14 +266,10 @@ async function backfillUserHistory(user: {
contentTitle: item.title || item.full_title || "Unknown",
},
});
totalCredits += creditsEarned;
totalMinutes += watchDurationMinutes;
processedCount++;
}
// Update user stats
if (totalCredits > 0) {
if (totalCredits > 0)
await prisma.user.update({
where: { id: user.id },
data: {
@@ -400,10 +277,6 @@ async function backfillUserHistory(user: {
watchTimeMinutes: { increment: totalMinutes },
},
});
console.log(
`Backfilled ${processedCount} watch events for ${user.plexUsername}: ${totalCredits} $COOP, ${totalMinutes} minutes`,
);
}
} catch (error) {
console.error(
`Failed to backfill history for ${user.plexUsername}:`,
+29 -210
View File
@@ -1,223 +1,42 @@
import crypto from "crypto";
import { Router } from "express";
import {
type AuthenticatedRequest,
authenticate,
requireAdmin,
} from "../middleware/auth";
import { type AuthenticatedRequest, authenticate, requireAdmin } from "../middleware/auth";
import { asyncHandler } from "../middleware/errorHandler";
import { createWallet } from "../services/solana";
import { prisma } from "../utils/prisma";
const router = Router();
const ENCRYPTION_KEY =
process.env.ENCRYPTION_KEY || "default-key-32-chars-long!!!!!";
function encrypt(text: string): string {
const iv = crypto.randomBytes(16);
const key = crypto.createHash("sha256").update(ENCRYPTION_KEY).digest();
const cipher = crypto.createCipheriv("aes-256-gcm", 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}`;
}
router.get("/", authenticate, asyncHandler(async (req: AuthenticatedRequest, res) => {
const user = await prisma.user.findUnique({ where: { id: req.user!.id }, select: { totalEarned: true, totalSpent: true } });
res.json({ hasWallet: false, balance: (user?.totalEarned || 0) - (user?.totalSpent || 0), totalEarned: user?.totalEarned || 0, totalSpent: user?.totalSpent || 0 });
}));
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 key = crypto.createHash("sha256").update(ENCRYPTION_KEY).digest();
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}
router.post("/create", authenticate, asyncHandler(async (_req: AuthenticatedRequest, res) => {
res.status(410).json({ error: "Wallets removed" });
}));
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,
},
});
router.post("/connect", authenticate, asyncHandler(async (_req: AuthenticatedRequest, res) => {
res.status(410).json({ error: "Wallets removed" });
}));
if (!user?.walletAddress) {
return res.json({
hasWallet: false,
balance: 0,
totalEarned: user?.totalEarned || 0,
totalSpent: user?.totalSpent || 0,
});
}
router.post("/backup", authenticate, asyncHandler(async (_req: AuthenticatedRequest, res) => {
res.status(410).json({ error: "Wallets removed" });
}));
const dbBalance = user.totalEarned - user.totalSpent;
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, email: true, isAdmin: true, isActive: true, totalEarned: true, totalSpent: true, watchTimeMinutes: true, createdAt: true } });
if (!user) return res.status(404).json({ error: "User not found" });
res.json({ ...user, balance: user.totalEarned - user.totalSpent });
}));
res.json({
hasWallet: true,
address: user.walletAddress,
balance: dbBalance,
totalEarned: user.totalEarned,
totalSpent: user.totalSpent,
});
}),
);
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" });
}
const wallet = createWallet();
const encryptedKey = encrypt(wallet.secretKey);
await prisma.user.update({
where: { id: req.user!.id },
data: {
walletAddress: wallet.publicKey,
encryptedPrivateKey: encryptedKey,
},
});
res.json({
success: true,
address: wallet.publicKey,
});
}),
);
router.post(
"/connect",
authenticate,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const { address } = req.body;
if (!address) {
return res.status(400).json({ error: "Address required" });
}
const existing = await prisma.user.findFirst({
where: { walletAddress: address },
});
if (existing && existing.id !== req.user!.id) {
return res.status(400).json({ error: "Wallet already in use" });
}
await prisma.user.update({
where: { id: req.user!.id },
data: { walletAddress: address },
});
res.json({ success: true, address });
}),
);
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 backup available" });
}
const privateKey = decrypt(user.encryptedPrivateKey);
res.json({ success: true, privateKey });
}),
);
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,
email: true,
isAdmin: true,
isActive: 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,
balance: user.totalEarned - user.totalSpent,
});
}),
);
router.post(
"/admin/:userId/adjust",
authenticate,
requireAdmin,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const { amount, reason } = req.body;
if (!amount || Number.isNaN(Number(amount)) || Number(amount) === 0) {
return res.status(400).json({ error: "Valid amount required" });
}
const user = await prisma.user.findUnique({
where: { id: req.params.userId },
});
if (!user) {
return res.status(404).json({ error: "User not found" });
}
const delta = Number(amount);
const type = delta > 0 ? "BONUS" : "ADJUSTMENT";
await prisma.transaction.create({
data: {
userId: user.id,
type,
amount: Math.abs(delta),
description: reason || "Admin adjustment",
contentTitle: reason || "Admin adjustment",
},
});
await prisma.user.update({
where: { id: user.id },
data: {
totalEarned: delta > 0 ? { increment: delta } : undefined,
totalSpent: delta < 0 ? { increment: Math.abs(delta) } : undefined,
},
});
res.json({ success: true });
}),
);
router.post("/admin/:userId/adjust", authenticate, requireAdmin, asyncHandler(async (req: AuthenticatedRequest, res) => {
const { amount, reason } = req.body;
if (!amount || Number.isNaN(Number(amount)) || Number(amount) === 0) return res.status(400).json({ error: "Valid amount required" });
const user = await prisma.user.findUnique({ where: { id: req.params.userId } });
if (!user) return res.status(404).json({ error: "User not found" });
const delta = Number(amount);
await prisma.transaction.create({ data: { userId: user.id, type: "ADJUSTMENT", amount: Math.abs(delta), description: reason || "Admin adjustment", contentTitle: reason || "Admin adjustment" } });
await prisma.user.update({ where: { id: user.id }, data: delta > 0 ? { totalEarned: { increment: delta } } : { totalSpent: { increment: Math.abs(delta) } } });
res.json({ success: true });
}));
export { router as walletRouter };
-267
View File
@@ -1,267 +0,0 @@
import {
createBurnInstruction,
createMint,
createMintToInstruction,
getAccount,
getOrCreateAssociatedTokenAccount,
} from "@solana/spl-token";
import {
Connection,
Keypair,
PublicKey,
sendAndConfirmTransaction,
Transaction,
} from "@solana/web3.js";
import bs58 from "bs58";
import { prisma } from "../utils/prisma";
const RPC_URL = process.env.SOLANA_RPC_URL || "https://api.devnet.solana.com";
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 raw = process.env.SOLANA_MINT_AUTHORITY_KEYPAIR.trim();
let secretKey: Uint8Array;
if (raw.startsWith("[")) {
secretKey = new Uint8Array(JSON.parse(raw));
} else {
secretKey = bs58.decode(raw);
}
mintAuthority = Keypair.fromSecretKey(secretKey);
}
} catch (error) {
console.warn("Mint authority not configured");
}
export const connection = new Connection(RPC_URL, "confirmed");
let cachedMint: PublicKey | null = null;
// Get or create token mint address
export async function getTokenMint(): Promise<PublicKey | null> {
try {
// Return cached mint
if (cachedMint) {
return cachedMint;
}
// Check env first
if (process.env.SOLANA_MINT_ADDRESS) {
cachedMint = new PublicKey(process.env.SOLANA_MINT_ADDRESS);
return cachedMint;
}
// Auto-create mint on devnet if authority exists
if (mintAuthority && RPC_URL.includes("devnet")) {
try {
// Fund mint authority with SOL first
const balance = await connection.getBalance(mintAuthority.publicKey);
if (balance < 500000000) {
const signature = await connection.requestAirdrop(
mintAuthority.publicKey,
2 * 1000000000,
);
await connection.confirmTransaction(signature);
}
const mintKeypair = Keypair.generate();
const mint = await createMint(
connection,
mintAuthority,
mintAuthority.publicKey,
null,
DECIMALS,
mintKeypair,
);
cachedMint = mint;
console.log(`Token mint created: ${mint.toBase58()}`);
console.log(
`Add SOLANA_MINT_ADDRESS=${mint.toBase58()} to your .env to persist it`,
);
return mint;
} catch {
// Devnet faucet rate-limited, will use DB-only credits
return null;
}
}
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) {
console.warn("Mint authority not configured, using DB-only credits");
return `db-only-${Date.now()}`;
}
try {
const userPublicKey = new PublicKey(userWalletAddress);
const mint = await getTokenMint();
if (!mint) {
console.warn("Token mint not available, using DB-only credits");
return `db-only-${Date.now()}`;
}
// Get or create user's token account
const tokenAccount = await getOrCreateTokenAccount(userPublicKey, mint);
// Calculate amount with decimals
const amountWithDecimals = amount * 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("Solana minting failed, using DB-only credits:", error);
return `db-only-${Date.now()}`;
}
}
// 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 * 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) / 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;
}
}