Skip to content
74 changes: 74 additions & 0 deletions backend/src/api/controllers/market.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,80 @@ export async function resolveDisputeHandler(req: Request, res: Response): Promis
}
}

/**
* POST /api/admin/markets/:marketId/resolve
* Body: { outcome, source }
* Admin-protected. Resolves a market by ID and writes an audit log entry.
*/
export async function resolveMarketByIdHandler(req: Request, res: Response): Promise<void> {
try {
const { marketId } = req.params;
const { outcome, source } = req.body;

if (!outcome || !VALID_OUTCOMES.includes(outcome)) {
res.status(400).json({
error: "Invalid or missing outcome",
code: "INVALID_OUTCOME",
allowed: VALID_OUTCOMES,
});
return;
}

if (!source) {
res.status(400).json({ error: "Missing source", code: "MISSING_SOURCE" });
return;
}

const market = await marketService.resolveMarket(marketId, outcome, source, "admin");
res.status(200).json({ market, message: "Market resolved successfully" });
} catch (error) {
res.status(500).json({ error: "Failed to resolve market", code: "INTERNAL_ERROR" });
}
}

/**
* POST /api/admin/markets/:marketId/cancel
* Body: { reason? }
* Admin-protected. Cancels a market and writes an audit log entry.
*/
export async function cancelMarketHandler(req: Request, res: Response): Promise<void> {
try {
const { marketId } = req.params;
const { reason } = req.body;

const market = await marketService.cancelMarket(marketId, "admin", reason);
res.status(200).json({ market, message: "Market cancelled" });
} catch (error) {
res.status(500).json({ error: "Failed to cancel market", code: "INTERNAL_ERROR" });
}
}

/**
* POST /api/admin/markets/:marketId/dispute-resolve
* Body: { overrideOutcome, resolution? }
* Admin-protected. Resolves a disputed market with an override and writes an audit log entry.
*/
export async function resolveDisputeByIdHandler(req: Request, res: Response): Promise<void> {
try {
const { marketId } = req.params;
const { overrideOutcome, resolution } = req.body;

if (!overrideOutcome || !VALID_OUTCOMES.includes(overrideOutcome)) {
res.status(400).json({
error: "Invalid or missing overrideOutcome",
code: "INVALID_OUTCOME",
allowed: VALID_OUTCOMES,
});
return;
}

const market = await marketService.resolveMarketDispute(marketId, overrideOutcome, "admin", resolution);
res.status(200).json({ market, message: "Dispute resolved" });
} catch (error) {
res.status(500).json({ error: "Failed to resolve dispute", code: "INTERNAL_ERROR" });
}
}

/**
* GET /api/admin/markets/pending
*/
Expand Down
40 changes: 40 additions & 0 deletions backend/src/api/controllers/user.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Request, Response } from "express";
import * as userService from "../../services/user.service";

/**
* GET /api/users/:address
* Returns user profile data for the given wallet address.
*/
export async function getUserHandler(req: Request, res: Response): Promise<void> {
try {
const { address } = req.params;

const user = await userService.getUserByAddress(address);

if (!user) {
res.status(404).json({ error: "User not found", code: "NOT_FOUND" });
return;
}

res.status(200).json({ user });
} catch (error) {
res.status(500).json({ error: "Failed to fetch user", code: "INTERNAL_ERROR" });
}
}

/**
* PUT /api/users/:address
* Body: { displayName?, avatarUrl? }
* Requires wallet-signature auth matching :address.
*/
export async function updateUserHandler(req: Request, res: Response): Promise<void> {
try {
const { address } = req.params;
const { displayName, avatarUrl } = req.body;

const user = await userService.updateUser(address, { displayName, avatarUrl });
res.status(200).json({ user });
} catch (error) {
res.status(500).json({ error: "Failed to update user", code: "INTERNAL_ERROR" });
}
}
63 changes: 63 additions & 0 deletions backend/src/api/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Request, Response, NextFunction } from "express";
import { Keypair } from "@stellar/stellar-sdk";

export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
const key = req.headers["x-admin-api-key"];
Expand All @@ -8,3 +9,65 @@ export function requireAdmin(req: Request, res: Response, next: NextFunction): v
}
next();
}

/**
* Middleware that verifies a Stellar wallet signature to prove ownership
* of the address in req.params.address.
*
* Expects headers:
* x-wallet-signature: base64-encoded signature
* x-wallet-message: the message that was signed (must contain a recent 13-digit epoch timestamp)
*/
export function requireWalletAuth(req: Request, res: Response, next: NextFunction): void {
const address = req.params.address;
const signature = req.headers["x-wallet-signature"] as string | undefined;
const message = req.headers["x-wallet-message"] as string | undefined;

if (!address || !signature || !message) {
res.status(401).json({
error: "Missing wallet authentication headers",
code: "UNAUTHORIZED",
});
return;
}

// Replay protection: message must contain a 13-digit epoch timestamp
const timestampMatch = message.match(/\d{13}/);
if (!timestampMatch) {
res.status(400).json({
error: "Message must contain a 13-digit epoch timestamp",
code: "INVALID_MESSAGE",
});
return;
}

const timestamp = parseInt(timestampMatch[0], 10);
const fiveMinutes = 5 * 60 * 1000;
if (Date.now() - timestamp > fiveMinutes) {
res.status(401).json({
error: "Signature expired",
code: "SIGNATURE_EXPIRED",
});
return;
}

try {
const keypair = Keypair.fromPublicKey(address);
const isValid = keypair.verify(
Buffer.from(message),
Buffer.from(signature, "base64")
);

if (!isValid) {
res.status(403).json({ error: "Invalid signature", code: "FORBIDDEN" });
return;
}

next();
} catch {
res.status(400).json({
error: "Invalid wallet address or signature format",
code: "INVALID_WALLET_ADDRESS",
});
}
}
10 changes: 10 additions & 0 deletions backend/src/api/routes/user.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Router } from "express";
import { getUserHandler, updateUserHandler } from "../controllers/user.controller";
import { requireWalletAuth } from "../middleware/auth";

const router = Router();

router.get("/:address", getUserHandler);
router.put("/:address", requireWalletAuth, updateUserHandler);

export default router;
91 changes: 91 additions & 0 deletions backend/src/services/market.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,3 +194,94 @@ export async function getMarketLeaderboard(

return sorted.slice(skip, skip + limit);
}

/**
* Admin resolves a market with a final outcome.
* Updates market status to Resolved and writes an AdminLog entry.
*/
export async function resolveMarket(
marketId: string,
outcome: Outcome,
source: string,
admin: string
): Promise<Market> {
const market = await prisma.market.update({
where: { id: marketId },
data: {
status: MarketStatus.Resolved,
outcome,
resolvedAt: new Date(),
},
});

await prisma.adminLog.create({
data: {
action: "RESOLVE_MARKET",
actor: admin,
target: marketId,
metadata: { outcome, source },
},
});

return market;
}

/**
* Admin cancels a market (e.g., fight postponed, insufficient liquidity).
* Updates market status to Cancelled and writes an AdminLog entry.
*/
export async function cancelMarket(
marketId: string,
admin: string,
reason?: string
): Promise<Market> {
const market = await prisma.market.update({
where: { id: marketId },
data: {
status: MarketStatus.Cancelled,
resolvedAt: new Date(),
},
});

await prisma.adminLog.create({
data: {
action: "CANCEL_MARKET",
actor: admin,
target: marketId,
metadata: reason ? { reason } : undefined,
},
});

return market;
}

/**
* Admin resolves a disputed market with an override outcome.
* Writes an AdminLog entry recording the resolution.
*/
export async function resolveMarketDispute(
marketId: string,
overrideOutcome: Outcome,
admin: string,
resolution?: string
): Promise<Market> {
const market = await prisma.market.update({
where: { id: marketId },
data: {
status: MarketStatus.Resolved,
outcome: overrideOutcome,
resolvedAt: new Date(),
},
});

await prisma.adminLog.create({
data: {
action: "RESOLVE_DISPUTE",
actor: admin,
target: marketId,
metadata: { overrideOutcome, resolution },
},
});

return market;
}
73 changes: 73 additions & 0 deletions backend/src/services/user.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

export interface UserProfile {
address: string;
displayName?: string;
avatarUrl?: string;
createdAt: Date;
updatedAt: Date;
}

export interface UpdateUserDTO {
displayName?: string;
avatarUrl?: string;
}

/**
* Fetches a user profile by wallet address.
* Returns null if the user has no profile record yet.
*/
export async function getUserByAddress(
address: string
): Promise<UserProfile | null> {
// For now, aggregate user data from bets and return a basic profile.
// A full User table would be added later in the schema.
const betCount = await prisma.bet.count({
where: { bettor: address },
});

if (betCount === 0) return null;

const firstBet = await prisma.bet.findFirst({
where: { bettor: address },
orderBy: { placedAt: "asc" },
});

return {
address,
createdAt: firstBet?.placedAt ?? new Date(),
updatedAt: new Date(),
};
}

/**
* Updates a user's profile metadata.
* Currently stores profile data via an AdminLog pattern; a full User
* table with displayName/avatarUrl fields should be added to the Prisma
* schema for production use.
*/
export async function updateUser(
address: string,
data: UpdateUserDTO
): Promise<UserProfile> {
const existing = await getUserByAddress(address);

await prisma.adminLog.create({
data: {
action: "UPDATE_USER",
actor: address,
target: address,
metadata: data as Record<string, unknown>,
},
});

return {
address,
displayName: data.displayName,
avatarUrl: data.avatarUrl,
createdAt: existing?.createdAt ?? new Date(),
updatedAt: new Date(),
};
}
Loading