Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 46 additions & 74 deletions backend/src/api/controllers/OracleController.ts
Original file line number Diff line number Diff line change
@@ -1,117 +1,89 @@
// ============================================================
// BOXMEOUT — Oracle Controller
// Protected by oracle API key middleware.
// ============================================================

import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { createHmac, timingSafeEqual } from 'crypto';
import { AppError } from '../../utils/AppError';
import { validateBody } from '../middleware/validate';
import { validate } from '../middleware/validate';
import * as OracleService from '../../oracle/OracleService';
import { redis } from '../../config/redis';
import { z } from 'zod';

// ---------------------------------------------------------------------------
// Zod schema for POST /api/oracle/submit body
// ---------------------------------------------------------------------------
const submitOracleResultSchema = z.object({
match_id: z.string().min(1, 'match_id is required'),
outcome: z.enum(['fighter_a', 'fighter_b', 'draw', 'no_contest'], {
errorMap: () => ({
message: "outcome must be one of: fighter_a, fighter_b, draw, no_contest",
}),
errorMap: () => ({ message: 'outcome must be one of: fighter_a, fighter_b, draw, no_contest' }),
}),
reported_at: z
.string()
.datetime({ message: 'reported_at must be a valid ISO 8601 datetime string' }),
signature: z
.string()
.regex(/^[0-9a-fA-F]+$/, 'signature must be a hex-encoded string')
.min(1, 'signature is required'),
oracle_address: z
.string()
.min(1, 'oracle_address is required'),
reported_at: z.string().datetime({ message: 'reported_at must be a valid ISO 8601 datetime string' }),
signature: z.string().regex(/^[0-9a-fA-F]+$/, 'signature must be a hex-encoded string').min(1),
oracle_address: z.string().min(1, 'oracle_address is required'),
});

// Export the validation middleware so the route can apply it before the handler
export const validateSubmitOracleResult = validateBody(submitOracleResultSchema);
export const validateSubmitOracleResult = validate(submitOracleResultSchema, 'body');

const RATE_LIMIT_TTL = 60; // seconds

/**
* POST /api/oracle/submit
* Body: { match_id, outcome, reported_at, signature, oracle_address }
*
* Receives a signed OracleReport from an authorized oracle.
* Steps:
* 1. Validate X-Oracle-Key header against ORACLE_API_KEY env var
* 2. Validate request body with Zod schema (applied as middleware before this handler)
* 3. Call OracleService.verifyOracleReport() — respond 401 if invalid
* 4. Call OracleService.submitFightResult()
* 5. Respond 200 with { tx_hash, report_id }
*
* Protected by oracle API key header: X-Oracle-Key
* 1. Verify HMAC-SHA256 signature using ORACLE_HMAC_SECRET
* 2. Rate-limit: 1 submission per match_id per 60 seconds (Redis)
* 3. Respond 202 immediately; call OracleService.submitFightResult() async
*/
export async function submitOracleResult(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
try {
// Step 1 — Validate X-Oracle-Key header
const apiKey = req.headers['x-oracle-key'];
const expectedKey = process.env.ORACLE_API_KEY;

if (!expectedKey) {
// Misconfigured server — fail closed
return next(new AppError(500, 'Oracle API key is not configured'));
}

if (!apiKey || apiKey !== expectedKey) {
return next(new AppError(401, 'Invalid or missing X-Oracle-Key header'));
const hmacSecret = process.env.ORACLE_HMAC_SECRET;
if (!hmacSecret) {
return next(new AppError(500, 'ORACLE_HMAC_SECRET is not configured'));
}

// Step 2 — Body already validated and typed by validateSubmitOracleResult middleware
const { match_id, outcome, reported_at, signature, oracle_address } =
req.body as z.infer<typeof submitOracleResultSchema>;

// Build a partial OracleReport for verification (id/accepted/tx_hash/created_at
// are not known yet — verifyOracleReport only needs the crypto fields)
const reportToVerify = {
match_id,
outcome,
reported_at: new Date(reported_at),
signature,
oracle_address,
};
// Step 1 — Verify HMAC-SHA256 signature
// Canonical message: match_id|outcome|reported_at|oracle_address
const message = `${match_id}|${outcome}|${reported_at}|${oracle_address}`;
const expected = createHmac('sha256', hmacSecret).update(message).digest('hex');

// Step 3 — Verify signature + whitelist
const isValid = await OracleService.verifyOracleReport(
reportToVerify as Parameters<typeof OracleService.verifyOracleReport>[0],
);
let sigValid = false;
try {
sigValid = timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'));
} catch {
sigValid = false;
}

if (!isValid) {
return next(new AppError(401, 'Oracle report signature is invalid or oracle is not whitelisted'));
if (!sigValid) {
return next(new AppError(401, 'Invalid HMAC signature'));
}

// Step 4 — Submit the fight result on-chain and persist to DB
const savedReport = await OracleService.submitFightResult(
match_id,
outcome as OracleService.FightOutcome,
);
// Step 2 — Rate-limit: 1 submission per match_id per 60 seconds
const rateLimitKey = `oracle:ratelimit:${match_id}`;
const existing = await redis.set(rateLimitKey, '1', 'EX', RATE_LIMIT_TTL, 'NX');
if (existing === null) {
return next(new AppError(429, `Rate limit exceeded: match_id ${match_id} already submitted within 60 seconds`));
}

// Step 3 — Respond 202 immediately
res.status(202).json({ message: 'Accepted' });

// Step 5 — Respond with tx_hash and report_id
res.status(200).json({
tx_hash: savedReport.tx_hash,
report_id: savedReport.id,
});
// Step 4 — Async resolution (fire-and-forget)
OracleService.submitFightResult(match_id, outcome as OracleService.FightOutcome).catch(
(err) => {
// Log but don't crash — response already sent
console.error({ err, match_id, outcome }, 'submitOracleResult: async submitFightResult failed');
},
);
} catch (err) {
next(err);
}
}

/**
* GET /api/oracle/reports/:match_id
*
* Returns all oracle reports (accepted and rejected) for a fight.
* Public endpoint — used for transparency and dispute investigation.
* Responds 200 with OracleReport[].
* Returns all oracle reports for a fight.
*/
export async function getOracleReports(
_req: Request,
Expand Down
27 changes: 11 additions & 16 deletions backend/src/api/middleware/validate.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,29 @@
import type { Request, Response, NextFunction } from 'express';
import { ZodSchema, ZodError, ZodIssue } from 'zod';

type Target = 'body' | 'params' | 'query';

function formatErrors(err: ZodError) {
return err.issues.map((e: ZodIssue) => ({
field: e.path.join('.'),
message: e.message,
}));
}

export function validateBody(schema: ZodSchema) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body);
if (!result.success) {
res.status(400).json({ errors: formatErrors(result.error) });
return;
}
req.body = result.data;
next();
};
}

export function validateQuery(schema: ZodSchema) {
export function validate(schema: ZodSchema, target: Target = 'body') {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.query);
const result = schema.safeParse(req[target]);
if (!result.success) {
res.status(400).json({ errors: formatErrors(result.error) });
res.status(422).json({ errors: formatErrors(result.error) });
return;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
req.query = result.data as any;
(req as any)[target] = result.data;
next();
};
}

// Convenience aliases kept for backward compatibility
export const validateBody = (schema: ZodSchema) => validate(schema, 'body');
export const validateQuery = (schema: ZodSchema) => validate(schema, 'query');
export const validateParams = (schema: ZodSchema) => validate(schema, 'params');
25 changes: 21 additions & 4 deletions backend/src/middleware/requireAdminJwt.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,39 @@ import type { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { AppError } from '../utils/AppError';

export interface AdminJwtPayload extends jwt.JwtPayload {
role: 'admin';
}

declare global {
namespace Express {
interface Request {
admin?: AdminJwtPayload;
}
}
}

export function requireAdminJwt(req: Request, _res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return next(new AppError(401, 'Missing or invalid Authorization header'));
}

const token = authHeader.slice(7);
const secret = process.env.JWT_SECRET;
const secret = process.env.ADMIN_JWT_SECRET ?? process.env.JWT_SECRET;

if (!secret) {
return next(new AppError(500, 'JWT secret is not configured'));
}

try {
const payload = jwt.verify(token, secret!) as jwt.JwtPayload;
const payload = jwt.verify(token, secret) as AdminJwtPayload;
if (payload.role !== 'admin') {
return next(new AppError(403, 'Forbidden: admin role required'));
}
req.admin = payload;
next();
} catch (err) {
next(new AppError(401, 'Invalid or expired token'));
} catch {
next(new AppError(403, 'Invalid or expired token'));
}
}
Loading
Loading