chore: remove solana wallet stack
This commit is contained in:
@@ -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
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user