From fee4d8ce1b9dde3dbaebf5da50747b43f467bedd Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Tue, 21 Apr 2026 15:37:06 -0400 Subject: [PATCH] fix(balance): use database credits instead of on-chain balance Balance checks were reading on-chain Solana token balance (0 during db-only mode). Now wallet and overseer routes return user.totalEarned - user.totalSpent as the usable credit balance. This makes credits spendable even when devnet faucet is dry. - /wallet returns dbBalance, with onChainBalance as extra info - /overseer/balance returns dbBalance for request eligibility - /overseer/request checks dbBalance before approving spend - /wallet/admin returns dbBalance --- backend/src/routes/overseer.ts | 350 +++++++++++++++++---------------- backend/src/routes/wallet.ts | 16 +- 2 files changed, 185 insertions(+), 181 deletions(-) diff --git a/backend/src/routes/overseer.ts b/backend/src/routes/overseer.ts index 4b1ae1d..4fe5b57 100644 --- a/backend/src/routes/overseer.ts +++ b/backend/src/routes/overseer.ts @@ -1,217 +1,221 @@ -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'; +import axios from "axios"; +import { Router } from "express"; +import { io } from "../index"; +import { type AuthenticatedRequest, authenticate } from "../middleware/auth"; +import { asyncHandler } from "../middleware/errorHandler"; +import { prisma } from "../utils/prisma"; const router = Router(); -const OVERSEER_URL = process.env.OVERSEER_URL || ''; -const OVERSEER_API_KEY = process.env.OVERSEER_API_KEY || ''; +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 - } + 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 - }); - }) +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 } - }); +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 - }); - } + if (!user?.walletAddress) { + return res.json({ + hasWallet: false, + balance: 0, + canRequest: false, + }); + } - const balance = await getTokenBalance(user.walletAddress); - const settings = await prisma.systemSettings.findFirst(); + const settings = await prisma.systemSettings.findFirst(); + const dbBalance = user.totalEarned - user.totalSpent; - res.json({ - hasWallet: true, - balance, - canRequestMovie: balance >= (settings?.movieRequestCost || 100), - canRequestTV: balance >= (settings?.tvRequestCost || 200), - costs: { - movie: settings?.movieRequestCost || 100, - tv: settings?.tvRequestCost || 200 - } - }); - }) + res.json({ + hasWallet: true, + balance: dbBalance, + canRequestMovie: dbBalance >= (settings?.movieRequestCost || 100), + canRequestTV: dbBalance >= (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' }); - } +router.get( + "/search", + authenticate, + asyncHandler(async (req: AuthenticatedRequest, res) => { + const { query } = req.query; - const response = await overseerClient.get('/search', { - params: { query } - }); + if (!query) { + return res.status(400).json({ error: "Query required" }); + } - res.json(response.data); - }) + 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; +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' }); - } + if (!mediaType || !mediaId || !title) { + return res.status(400).json({ error: "Missing required fields" }); + } - const user = await prisma.user.findUnique({ - where: { id: req.user!.id } - }); + const user = await prisma.user.findUnique({ + where: { id: req.user!.id }, + }); - if (!user?.walletAddress) { - return res.status(400).json({ error: 'Wallet required' }); - } + 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); - } - } + const settings = await prisma.systemSettings.findFirst(); - // Check balance - const balance = await getTokenBalance(user.walletAddress); - if (balance < cost) { - return res.status(400).json({ - error: 'Insufficient balance', - required: cost, - current: balance - }); - } + // 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); + } + } - // Create request in Overseer - const overseerRequest = await overseerClient.post('/request', { - mediaType, - mediaId, - ...(seasons && { seasons }) - }); + // Check balance + const dbBalance = user.totalEarned - user.totalSpent; + if (dbBalance < cost) { + return res.status(400).json({ + error: "Insufficient balance", + required: cost, + current: dbBalance, + }); + } - // 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() - } - }); + // Create request in Overseer + const overseerRequest = await overseerClient.post("/request", { + mediaType, + mediaId, + ...(seasons && { seasons }), + }); - res.json({ - success: true, - request, - cost, - message: 'Request submitted. Credits will be deducted when approved.' - }); - }) + // 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; +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' }); - } + 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 } - }); + // 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' }); - } + 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 } - }); + // 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 - } - }); + // If approved, deduct credits + if (status === "APPROVED" && !request.user.totalSpent) { + // Note: Actual burning would happen here + // For now, we just record the transaction - // Update user stats - await prisma.user.update({ - where: { id: request.userId }, - data: { - totalSpent: { increment: request.creditsCost } - } - }); + 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, + }, + }); - // Emit update - io.to(`user:${request.userId}`).emit('credits_spent', { - amount: request.creditsCost, - title: request.title, - transaction - }); - } + // Update user stats + await prisma.user.update({ + where: { id: request.userId }, + data: { + totalSpent: { increment: request.creditsCost }, + }, + }); - res.json({ success: true, request: updatedRequest }); - }) + // 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/wallet.ts b/backend/src/routes/wallet.ts index 63bcebf..18d1adf 100644 --- a/backend/src/routes/wallet.ts +++ b/backend/src/routes/wallet.ts @@ -65,13 +65,16 @@ router.get( }); } - // Get on-chain balance - const balance = await getTokenBalance(user.walletAddress); + // Get on-chain balance (may be 0 if using DB-only credits) + const onChainBalance = await getTokenBalance(user.walletAddress); + // Usable credits are tracked in DB (works even when blockchain is down) + const dbBalance = user.totalEarned - user.totalSpent; res.json({ hasWallet: true, address: user.walletAddress, - balance, + balance: dbBalance, + onChainBalance, totalEarned: user.totalEarned, totalSpent: user.totalSpent, explorerUrl: `https://explorer.solana.com/address/${user.walletAddress}?cluster=devnet`, @@ -198,14 +201,11 @@ router.get( return res.status(404).json({ error: "User not found" }); } - let balance = 0; - if (user.walletAddress) { - balance = await getTokenBalance(user.walletAddress); - } + const dbBalance = user.totalEarned - user.totalSpent; res.json({ ...user, - balance, + balance: dbBalance, }); }), );