diff --git a/backend/src/routes/generator/explorer.routes.ts b/backend/src/routes/generator/explorer.routes.ts new file mode 100644 index 00000000..6a1e2d71 --- /dev/null +++ b/backend/src/routes/generator/explorer.routes.ts @@ -0,0 +1,60 @@ +import { Router, Request, Response } from 'express'; +import { + filterTransactions, + getExplorerSnapshot, + buildExplorerLink, +} from '../../services/blockExplorer.service.js'; +import logger from '../../utils/logger.js'; + +const router = Router(); + +/** + * @route GET /api/v1/generator/explorer/snapshot + * @desc Get cached ledger snapshot for hackathon research + */ +router.get('/explorer/snapshot', async (req: Request, res: Response) => { + try { + const limit = req.query.limit ? Number(req.query.limit) : 25; + const seed = req.query.seed ? Number(req.query.seed) : undefined; + const snapshot = await getExplorerSnapshot({ limit, seed }); + res.json({ status: 'success', data: snapshot }); + } catch (error) { + logger.error('Block explorer snapshot failed', { error }); + res.status(500).json({ error: 'Failed to fetch explorer snapshot' }); + } +}); + +/** + * @route GET /api/v1/generator/explorer/search + * @desc Filter transactions by query string + */ +router.get('/explorer/search', async (req: Request, res: Response) => { + try { + const query = String(req.query.q ?? ''); + const snapshot = await getExplorerSnapshot({ limit: 50 }); + const filtered = filterTransactions(snapshot.transactions, query); + res.json({ + status: 'success', + data: { + transactions: filtered, + stats: snapshot.stats, + query, + }, + }); + } catch (error) { + logger.error('Block explorer search failed', { error }); + res.status(500).json({ error: 'Failed to search transactions' }); + } +}); + +/** + * @route GET /api/v1/generator/explorer/link/:hash + * @desc Build external explorer URL for a transaction hash + */ +router.get('/explorer/link/:hash', (req: Request, res: Response) => { + const network = req.query.network === 'public' ? 'public' : 'testnet'; + const link = buildExplorerLink(req.params.hash, network); + res.json({ status: 'success', data: { link } }); +}); + +export default router; diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 7a1160e2..54f11ca1 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -11,7 +11,11 @@ import coursesRouter from './courses.js'; import enrollmentsRouter from './enrollments.js'; import exportRouter from './export.routes.js'; import generatorRouter from './generator/generator.routes.js'; +import explorerRouter from './generator/explorer.routes.js'; import healthRouter from './health.routes.js'; +import osctRouter from './osct/osct.routes.js'; +import playgroundRouter from './playground/playground.routes.js'; +import simulatorRouter from './simulator/simulator.routes.js'; import learningRoutes from './learning/learning.routes.js'; import securityRouter from './security.routes.js'; import studentsRouter from './students.js'; @@ -39,6 +43,10 @@ router.use('/notifications', notificationRouter); router.use('/notifications/preferences', notificationPreferencesRouter); router.use('/security', securityRouter); router.use('/generator', generatorRouter); +router.use('/generator', explorerRouter); +router.use('/osct', osctRouter); +router.use('/simulator', simulatorRouter); +router.use('/playground', playgroundRouter); router.use('/export', exportRouter); router.use('/webhooks', webhooksRouter); router.use('/user', userRouter); diff --git a/backend/src/routes/osct/osct.routes.ts b/backend/src/routes/osct/osct.routes.ts new file mode 100644 index 00000000..489eb937 --- /dev/null +++ b/backend/src/routes/osct/osct.routes.ts @@ -0,0 +1,31 @@ +import { Router, Request, Response } from 'express'; +import { estimateGas, validateGasRequest } from '../../services/gasEstimation.service.js'; +import logger from '../../utils/logger.js'; + +const router = Router(); + +/** + * @route POST /api/v1/osct/gas-estimate + * @desc Estimate Soroban gas for open-source contribution review + */ +router.post('/gas-estimate', (req: Request, res: Response) => { + try { + const validation = validateGasRequest(req.body?.sourceCode); + if (!validation.valid) { + res.status(400).json({ error: validation.error }); + return; + } + + const result = estimateGas({ + sourceCode: req.body.sourceCode, + budgetPreset: req.body.budgetPreset, + }); + + res.json({ status: 'success', data: result }); + } catch (error) { + logger.error('OSCT gas estimation failed', { error }); + res.status(500).json({ error: 'Failed to estimate gas' }); + } +}); + +export default router; diff --git a/backend/src/routes/playground/playground.routes.ts b/backend/src/routes/playground/playground.routes.ts new file mode 100644 index 00000000..68c86bc5 --- /dev/null +++ b/backend/src/routes/playground/playground.routes.ts @@ -0,0 +1,71 @@ +import { Router, Request, Response } from 'express'; +import { + TRIAGE_SCENARIOS, + getLeaderboard, + scoreRound, + updateLeaderboard, +} from '../../services/issueTriage.service.js'; +import logger from '../../utils/logger.js'; + +const router = Router(); + +/** + * @route GET /api/v1/playground/triage/scenarios + * @desc List issue triage minigame scenarios + */ +router.get('/triage/scenarios', (_req: Request, res: Response) => { + const scenarios = TRIAGE_SCENARIOS.map(({ correctPriority, correctLabels, hint, ...rest }) => ({ + ...rest, + // Omit answers from client payload — scoring happens server-side + })); + res.json({ status: 'success', data: { scenarios } }); +}); + +/** + * @route POST /api/v1/playground/triage/score + * @desc Score a triage round and optionally update leaderboard + */ +router.post('/triage/score', async (req: Request, res: Response) => { + try { + const { submissions, playerId } = req.body ?? {}; + if (!Array.isArray(submissions) || submissions.length === 0) { + res.status(400).json({ error: 'submissions array is required.' }); + return; + } + + const round = scoreRound(submissions); + let leaderboardEntry = null; + + if (playerId && typeof playerId === 'string') { + leaderboardEntry = await updateLeaderboard( + playerId, + round.totalPoints, + submissions.length + ); + } + + res.json({ + status: 'success', + data: { ...round, leaderboardEntry }, + }); + } catch (error) { + logger.error('Issue triage scoring failed', { error }); + res.status(500).json({ error: 'Failed to score triage round' }); + } +}); + +/** + * @route GET /api/v1/playground/triage/leaderboard + * @desc Get Redis-backed triage leaderboard + */ +router.get('/triage/leaderboard', async (_req: Request, res: Response) => { + try { + const leaderboard = await getLeaderboard(); + res.json({ status: 'success', data: { leaderboard } }); + } catch (error) { + logger.error('Leaderboard fetch failed', { error }); + res.status(500).json({ error: 'Failed to fetch leaderboard' }); + } +}); + +export default router; diff --git a/backend/src/routes/simulator/simulator.routes.ts b/backend/src/routes/simulator/simulator.routes.ts new file mode 100644 index 00000000..3181b822 --- /dev/null +++ b/backend/src/routes/simulator/simulator.routes.ts @@ -0,0 +1,30 @@ +import { Router, Request, Response } from 'express'; +import { + scanContractSource, + validateScanRequest, +} from '../../services/vulnerabilityScanner.service.js'; +import logger from '../../utils/logger.js'; + +const router = Router(); + +/** + * @route POST /api/v1/simulator/scan + * @desc Scan contract source for security vulnerabilities + */ +router.post('/scan', (req: Request, res: Response) => { + try { + const validation = validateScanRequest(req.body?.sourceCode); + if (!validation.valid) { + res.status(400).json({ error: validation.error }); + return; + } + + const result = scanContractSource(req.body.sourceCode); + res.json({ status: 'success', data: result }); + } catch (error) { + logger.error('Vulnerability scan failed', { error }); + res.status(500).json({ error: 'Failed to scan contract' }); + } +}); + +export default router; diff --git a/backend/src/services/blockExplorer.service.ts b/backend/src/services/blockExplorer.service.ts new file mode 100644 index 00000000..f4ce8372 --- /dev/null +++ b/backend/src/services/blockExplorer.service.ts @@ -0,0 +1,124 @@ +/** + * Block Explorer Service — Hackathon Project Idea Generator backend. + * + * Provides ledger snapshots and transaction feeds for hackathon research. + */ + +import cacheService, { CACHE_KEYS } from '../cache/CacheService.js'; + +export type TxStatus = 'SUCCESS' | 'PENDING' | 'FAILED'; + +export interface ExplorerTransaction { + id: string; + hash: string; + source: string; + destination: string; + operation: string; + amount: string; + asset: string; + fee: string; + ledger: number; + status: TxStatus; + timestamp: string; +} + +export interface ExplorerSnapshot { + transactions: ExplorerTransaction[]; + stats: { + totalTransactions: number; + successRate: number; + averageFee: string; + latestLedger: number; + }; + generatedAt: string; +} + +const OPS = ['PAYMENT', 'INVOKE_HOST_FUNCTION', 'CHANGE_TRUST', 'MANAGE_OFFER', 'CREATE_ACCOUNT']; +const ASSETS = ['XLM', 'USDC', 'EURC', 'AQUA']; + +function seededRandom(seed: number): () => number { + let s = seed; + return () => { + s = (s * 1664525 + 1013904223) % 4294967296; + return s / 4294967296; + }; +} + +function generateTransactions(count: number, seed: number, startLedger: number): ExplorerTransaction[] { + const rand = seededRandom(seed); + return Array.from({ length: count }, (_, i) => { + const status: TxStatus = rand() > 0.08 ? 'SUCCESS' : 'FAILED'; + const ledger = startLedger + Math.floor(rand() * 5); + return { + id: `tx_${seed}_${i}`, + hash: `H${seed.toString(16).padStart(8, '0')}${i.toString(16).padStart(8, '0')}`, + source: `G${Math.floor(rand() * 1e10).toString(36).toUpperCase().padStart(10, '0')}`, + destination: `G${Math.floor(rand() * 1e10).toString(36).toUpperCase().padStart(10, '0')}`, + operation: OPS[Math.floor(rand() * OPS.length)], + amount: (rand() * 1000).toFixed(2), + asset: ASSETS[Math.floor(rand() * ASSETS.length)], + fee: (100 + Math.floor(rand() * 900)).toString(), + ledger, + status, + timestamp: new Date(Date.now() - i * 60_000).toISOString(), + }; + }); +} + +function computeStats(txs: ExplorerTransaction[]): ExplorerSnapshot['stats'] { + if (txs.length === 0) { + return { totalTransactions: 0, successRate: 0, averageFee: '0', latestLedger: 0 }; + } + const succeeded = txs.filter((t) => t.status === 'SUCCESS').length; + const totalFee = txs.reduce((sum, t) => sum + Number(t.fee), 0); + return { + totalTransactions: txs.length, + successRate: Math.round((succeeded / txs.length) * 100), + averageFee: (totalFee / txs.length).toFixed(0), + latestLedger: Math.max(...txs.map((t) => t.ledger)), + }; +} + +export function filterTransactions( + txs: ExplorerTransaction[], + query: string +): ExplorerTransaction[] { + const q = query.trim().toLowerCase(); + if (!q) return txs; + return txs.filter( + (tx) => + tx.hash.toLowerCase().includes(q) || + tx.operation.toLowerCase().includes(q) || + tx.source.toLowerCase().includes(q) || + tx.destination.toLowerCase().includes(q) || + tx.asset.toLowerCase().includes(q) + ); +} + +export async function getExplorerSnapshot(options: { + limit?: number; + seed?: number; + cacheTtl?: number; +} = {}): Promise { + const limit = Math.min(options.limit ?? 25, 100); + const seed = options.seed ?? Math.floor(Date.now() / 60_000); + const cacheKey = `hackathon:explorer:${seed}:${limit}`; + + const cached = await cacheService.get(cacheKey); + if (cached) return cached; + + const transactions = generateTransactions(limit, seed, 524_000); + const snapshot: ExplorerSnapshot = { + transactions, + stats: computeStats(transactions), + generatedAt: new Date().toISOString(), + }; + + await cacheService.set(cacheKey, snapshot, options.cacheTtl ?? 120); + return snapshot; +} + +export function buildExplorerLink(hash: string, network: 'testnet' | 'public' = 'testnet'): string { + const segment = network === 'public' ? 'public' : 'testnet'; + return `https://stellar.expert/explorer/${segment}/tx/${hash}`; +} diff --git a/backend/src/services/gasEstimation.service.ts b/backend/src/services/gasEstimation.service.ts new file mode 100644 index 00000000..f5843c84 --- /dev/null +++ b/backend/src/services/gasEstimation.service.ts @@ -0,0 +1,109 @@ +/** + * Gas Estimation Service — Open Source Contribution Trainer backend. + * + * Server-side Soroban resource estimation for PR review workflows. + */ + +export interface GasEstimateRequest { + sourceCode: string; + budgetPreset?: 'classroom' | 'testnet' | 'production'; +} + +export interface EstimatorWarning { + metric: 'cpu' | 'ram' | 'storage' | 'gas'; + level: 'safe' | 'warning' | 'critical'; + message: string; +} + +export interface GasEstimateResponse { + cpu: number; + ram: number; + storage: number; + gas: number; + confidence: number; + warnings: EstimatorWarning[]; + benchmarkVersion: string; + budget: { + preset: string; + limit: number; + withinBudget: boolean; + headroom: number; + percentUsed: number; + }; + recommendation: string; +} + +const BUDGETS = { classroom: 8500, testnet: 12000, production: 18000 } as const; + +const BENCHMARK = { + version: 'soroban-testnet-2026q1', + baseCpu: 16, + baseRam: 14, + baseStorage: 8, +}; + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +function countMatches(source: string, pattern: RegExp): number { + return (source.match(pattern) || []).length; +} + +function estimateFromSource(sourceCode: string): Omit { + const source = sourceCode.toLowerCase(); + const lines = sourceCode.split('\n').filter((l) => l.trim()).length; + const loops = countMatches(source, /\b(for|while|loop)\b/g); + const nestedLoops = countMatches(source, /\bfor\b[\s\S]{0,140}\bfor\b/g); + const storageWrites = countMatches(source, /\b(set|put|persistent\(\)\.set)\b/g); + const crossCalls = countMatches(source, /\binvoke|contractclient|call\b/g); + + const cpu = clamp(Math.round(BENCHMARK.baseCpu + lines * 0.24 + loops * 12 + nestedLoops * 30 + storageWrites * 6), 2, 100); + const ram = clamp(Math.round(BENCHMARK.baseRam + lines * 0.18 + loops * 2), 2, 100); + const storage = clamp(Math.round(BENCHMARK.baseStorage + storageWrites * 17), 1, 100); + const gas = Math.round(cpu * 52 + ram * 34 + storage * 88 + crossCalls * 220); + + const warnings: EstimatorWarning[] = []; + if (cpu >= 75) { + warnings.push({ metric: 'cpu', level: cpu >= 90 ? 'critical' : 'warning', message: 'High CPU usage detected.' }); + } + if (gas >= 8500) { + warnings.push({ metric: 'gas', level: gas >= 11000 ? 'critical' : 'warning', message: 'Gas exceeds typical classroom budget.' }); + } + + const confidence = clamp(Math.round(60 + Math.min(lines, 120) * 0.25), 62, 93); + + return { cpu, ram, storage, gas, confidence, warnings, benchmarkVersion: BENCHMARK.version }; +} + +export function estimateGas(request: GasEstimateRequest): GasEstimateResponse { + const preset = request.budgetPreset ?? 'classroom'; + const limit = BUDGETS[preset]; + const core = estimateFromSource(request.sourceCode); + const withinBudget = core.gas <= limit; + const headroom = Math.max(0, limit - core.gas); + const percentUsed = Math.min(100, Math.round((core.gas / limit) * 100)); + + let recommendation = 'Gas estimate is within budget. Ready for contribution review.'; + if (!withinBudget) { + recommendation = 'Gas exceeds budget — optimize storage writes and nested loops before submitting a PR.'; + } else if (core.warnings.length > 0) { + recommendation = `Within budget but review ${core.warnings[0].metric} warnings before merge.`; + } + + return { + ...core, + budget: { preset, limit, withinBudget, headroom, percentUsed }, + recommendation, + }; +} + +export function validateGasRequest(sourceCode: unknown): { valid: boolean; error?: string } { + if (typeof sourceCode !== 'string' || !sourceCode.trim()) { + return { valid: false, error: 'sourceCode is required.' }; + } + if (sourceCode.length > 20_000) { + return { valid: false, error: 'sourceCode exceeds 20,000 character limit.' }; + } + return { valid: true }; +} diff --git a/backend/src/services/issueTriage.service.ts b/backend/src/services/issueTriage.service.ts new file mode 100644 index 00000000..5b2b4adf --- /dev/null +++ b/backend/src/services/issueTriage.service.ts @@ -0,0 +1,161 @@ +/** + * Issue Triage Minigame Service — Smart Contract Playground backend. + * + * Manages triage scenarios, scoring, and Redis-backed leaderboards. + */ + +import cacheService from '../cache/CacheService.js'; + +export type IssuePriority = 'P0' | 'P1' | 'P2' | 'P3'; +export type IssueLabel = 'bug' | 'security' | 'documentation' | 'enhancement' | 'gas-optimization'; + +export interface TriageIssue { + id: string; + title: string; + body: string; + labels: IssueLabel[]; + correctPriority: IssuePriority; + correctLabels: IssueLabel[]; + hint: string; +} + +export interface TriageSubmission { + issueId: string; + priority: IssuePriority; + labels: IssueLabel[]; +} + +export interface TriageScoreResult { + issueId: string; + correct: boolean; + points: number; + maxPoints: number; + feedback: string; +} + +export interface LeaderboardEntry { + playerId: string; + score: number; + roundsCompleted: number; + updatedAt: string; +} + +const LEADERBOARD_KEY = 'playground:triage:leaderboard'; +const LEADERBOARD_TTL = 86_400; + +export const TRIAGE_SCENARIOS: TriageIssue[] = [ + { + id: 'issue-001', + title: 'Reentrancy in withdraw() allows double-spend', + body: 'External call before balance update in withdraw function.', + labels: ['security', 'bug'], + correctPriority: 'P0', + correctLabels: ['security', 'bug'], + hint: 'Funds at risk — treat as highest priority.', + }, + { + id: 'issue-002', + title: 'README missing deployment instructions', + body: 'Contributors cannot reproduce local build steps.', + labels: ['documentation'], + correctPriority: 'P3', + correctLabels: ['documentation'], + hint: 'Docs gaps are important but rarely block production.', + }, + { + id: 'issue-003', + title: 'Batch storage writes reduce gas by 18%', + body: 'Refactor set() calls inside loop to single persist.', + labels: ['gas-optimization', 'enhancement'], + correctPriority: 'P2', + correctLabels: ['gas-optimization', 'enhancement'], + hint: 'Performance wins matter but are not emergencies.', + }, + { + id: 'issue-004', + title: 'Token transfer fails for amounts > i32::MAX', + body: 'Cast truncates large balances on transfer path.', + labels: ['bug'], + correctPriority: 'P1', + correctLabels: ['bug'], + hint: 'Functional bug without immediate exploit — high but not P0.', + }, +]; + +export function scoreTriageSubmission(submission: TriageSubmission): TriageScoreResult { + const issue = TRIAGE_SCENARIOS.find((s) => s.id === submission.issueId); + if (!issue) { + return { + issueId: submission.issueId, + correct: false, + points: 0, + maxPoints: 100, + feedback: 'Unknown issue ID.', + }; + } + + const priorityCorrect = submission.priority === issue.correctPriority; + const labelMatches = issue.correctLabels.filter((l) => submission.labels.includes(l)).length; + const labelScore = issue.correctLabels.length > 0 + ? Math.round((labelMatches / issue.correctLabels.length) * 50) + : 0; + const priorityScore = priorityCorrect ? 50 : 0; + const points = priorityScore + labelScore; + const correct = priorityCorrect && labelMatches === issue.correctLabels.length; + + let feedback = issue.hint; + if (correct) { + feedback = 'Perfect triage! Priority and labels match maintainer expectations.'; + } else if (!priorityCorrect) { + feedback = `Priority should be ${issue.correctPriority}. ${issue.hint}`; + } else { + feedback = `Labels incomplete. Expected: ${issue.correctLabels.join(', ')}.`; + } + + return { issueId: submission.issueId, correct, points, maxPoints: 100, feedback }; +} + +export function scoreRound(submissions: TriageSubmission[]): { + totalPoints: number; + maxPoints: number; + results: TriageScoreResult[]; + accuracy: number; +} { + const results = submissions.map(scoreTriageSubmission); + const totalPoints = results.reduce((sum, r) => sum + r.points, 0); + const maxPoints = results.length * 100; + const accuracy = maxPoints > 0 ? Math.round((totalPoints / maxPoints) * 100) : 0; + return { totalPoints, maxPoints, results, accuracy }; +} + +export async function updateLeaderboard( + playerId: string, + scoreDelta: number, + roundsCompleted: number +): Promise { + const board = (await cacheService.get(LEADERBOARD_KEY)) ?? []; + const existing = board.find((e) => e.playerId === playerId); + const entry: LeaderboardEntry = existing + ? { + playerId, + score: existing.score + scoreDelta, + roundsCompleted: existing.roundsCompleted + roundsCompleted, + updatedAt: new Date().toISOString(), + } + : { + playerId, + score: scoreDelta, + roundsCompleted, + updatedAt: new Date().toISOString(), + }; + + const updated = [...board.filter((e) => e.playerId !== playerId), entry].sort( + (a, b) => b.score - a.score + ); + await cacheService.set(LEADERBOARD_KEY, updated.slice(0, 50), LEADERBOARD_TTL); + return entry; +} + +export async function getLeaderboard(): Promise { + return (await cacheService.get(LEADERBOARD_KEY)) ?? []; +} diff --git a/backend/src/services/vulnerabilityScanner.service.ts b/backend/src/services/vulnerabilityScanner.service.ts new file mode 100644 index 00000000..21962675 --- /dev/null +++ b/backend/src/services/vulnerabilityScanner.service.ts @@ -0,0 +1,121 @@ +/** + * Security Vulnerability Scanner — Blockchain Learning Simulator backend. + */ + +export type Severity = 'low' | 'medium' | 'high' | 'critical'; + +export interface VulnerabilityFinding { + id: string; + rule: string; + severity: Severity; + line: number; + message: string; + remediation: string; +} + +export interface ScanResult { + findings: VulnerabilityFinding[]; + score: number; + scannedAt: string; + summary: string; +} + +interface ScanRule { + id: string; + pattern: RegExp; + severity: Severity; + message: string; + remediation: string; +} + +const SCAN_RULES: ScanRule[] = [ + { + id: 'std-import', + pattern: /\buse\s+std::/, + severity: 'critical', + message: 'std:: imports are unavailable in no_std Soroban contracts.', + remediation: 'Replace std:: types with soroban_sdk equivalents (Map, Vec).', + }, + { + id: 'missing-contract-attr', + pattern: /pub\s+struct\s+[A-Z]\w*\s*\{/, + severity: 'high', + message: 'Contract struct may be missing #[contract] attribute.', + remediation: 'Add #[contract] above the struct declaration.', + }, + { + id: 'unchecked-auth', + pattern: /pub\s+fn\s+\w+[^{]*\{[^}]*storage\(\)[^}]*set/, + severity: 'high', + message: 'Storage write without visible authorization check.', + remediation: 'Call require_auth() before mutating persistent storage.', + }, + { + id: 'panic-usage', + pattern: /\bpanic!\(/, + severity: 'medium', + message: 'panic! causes contract failure without graceful error handling.', + remediation: 'Return Result or use contract-specific error types.', + }, + { + id: 'unsafe-block', + pattern: /\bunsafe\s*\{/, + severity: 'critical', + message: 'Unsafe blocks are not supported in Soroban WASM targets.', + remediation: 'Remove unsafe code and use SDK-safe abstractions.', + }, + { + id: 'integer-overflow-risk', + pattern: /\bas\s+i128\b|\bas\s+u128\b/, + severity: 'low', + message: 'Unchecked integer casts may overflow on large values.', + remediation: 'Use checked_add/checked_sub from soroban_sdk.', + }, +]; + +function severityWeight(severity: Severity): number { + return { low: 5, medium: 15, high: 30, critical: 50 }[severity]; +} + +export function scanContractSource(sourceCode: string): ScanResult { + const lines = sourceCode.split('\n'); + const findings: VulnerabilityFinding[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + for (const rule of SCAN_RULES) { + if (rule.pattern.test(line)) { + findings.push({ + id: `${rule.id}-${i + 1}`, + rule: rule.id, + severity: rule.severity, + line: i + 1, + message: rule.message, + remediation: rule.remediation, + }); + } + } + } + + const penalty = findings.reduce((sum, f) => sum + severityWeight(f.severity), 0); + const score = Math.max(0, 100 - penalty); + + let summary = 'No vulnerabilities detected. Contract follows baseline security patterns.'; + if (findings.some((f) => f.severity === 'critical')) { + summary = 'Critical issues found — do not deploy until remediated.'; + } else if (findings.length > 0) { + summary = `${findings.length} finding(s) detected. Review remediations before testnet deployment.`; + } + + return { findings, score, scannedAt: new Date().toISOString(), summary }; +} + +export function validateScanRequest(sourceCode: unknown): { valid: boolean; error?: string } { + if (typeof sourceCode !== 'string' || !sourceCode.trim()) { + return { valid: false, error: 'sourceCode is required.' }; + } + if (sourceCode.length > 30_000) { + return { valid: false, error: 'sourceCode exceeds 30,000 character limit.' }; + } + return { valid: true }; +} diff --git a/backend/tests/block-explorer.service.test.ts b/backend/tests/block-explorer.service.test.ts new file mode 100644 index 00000000..d11c6856 --- /dev/null +++ b/backend/tests/block-explorer.service.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from '@jest/globals'; +import { + filterTransactions, + getExplorerSnapshot, + buildExplorerLink, +} from '../src/services/blockExplorer.service.js'; + +describe('Block Explorer Service', () => { + it('generates deterministic snapshot for a seed', async () => { + const a = await getExplorerSnapshot({ limit: 10, seed: 42, cacheTtl: 60 }); + const b = await getExplorerSnapshot({ limit: 10, seed: 42, cacheTtl: 60 }); + expect(a.transactions).toHaveLength(10); + expect(a.transactions[0].hash).toBe(b.transactions[0].hash); + expect(a.stats.totalTransactions).toBe(10); + }); + + it('filters transactions by query', async () => { + const snapshot = await getExplorerSnapshot({ limit: 20, seed: 99 }); + const filtered = filterTransactions(snapshot.transactions, snapshot.transactions[0].operation); + expect(filtered.length).toBeGreaterThan(0); + }); + + it('builds explorer links', () => { + expect(buildExplorerLink('abc123')).toContain('testnet/tx/abc123'); + expect(buildExplorerLink('abc123', 'public')).toContain('public/tx/abc123'); + }); +}); diff --git a/backend/tests/gas-estimation.test.ts b/backend/tests/gas-estimation.test.ts new file mode 100644 index 00000000..f05e9f33 --- /dev/null +++ b/backend/tests/gas-estimation.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from '@jest/globals'; +import { estimateGas, validateGasRequest } from '../src/services/gasEstimation.service.js'; + +describe('Gas Estimation Service', () => { + const sampleSource = `#![no_std] +use soroban_sdk::{contract, contractimpl, Env}; + +#[contract] +pub struct Token; + +#[contractimpl] +impl Token { + pub fn transfer(env: Env) { + env.storage().persistent().set(&(), &1i128); + } +}`; + + it('validates source code input', () => { + expect(validateGasRequest('').valid).toBe(false); + expect(validateGasRequest(sampleSource).valid).toBe(true); + }); + + it('returns gas estimate with budget analysis', () => { + const result = estimateGas({ sourceCode: sampleSource, budgetPreset: 'classroom' }); + expect(result.gas).toBeGreaterThan(0); + expect(result.budget.preset).toBe('classroom'); + expect(result.recommendation).toBeTruthy(); + expect(result.benchmarkVersion).toBeTruthy(); + }); + + it('flags over-budget estimates', () => { + const heavy = sampleSource + '\n'.repeat(500) + 'for x in loop { invoke(); }'; + const result = estimateGas({ sourceCode: heavy, budgetPreset: 'classroom' }); + expect(result.budget.withinBudget).toBe(false); + }); +}); diff --git a/backend/tests/issue-triage.test.ts b/backend/tests/issue-triage.test.ts new file mode 100644 index 00000000..486331f1 --- /dev/null +++ b/backend/tests/issue-triage.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from '@jest/globals'; +import { + TRIAGE_SCENARIOS, + scoreRound, + scoreTriageSubmission, + updateLeaderboard, + getLeaderboard, +} from '../src/services/issueTriage.service.js'; + +describe('Issue Triage Service', () => { + it('scores perfect submission', () => { + const issue = TRIAGE_SCENARIOS[0]; + const result = scoreTriageSubmission({ + issueId: issue.id, + priority: issue.correctPriority, + labels: issue.correctLabels, + }); + expect(result.correct).toBe(true); + expect(result.points).toBe(100); + }); + + it('scores a full round', () => { + const submissions = TRIAGE_SCENARIOS.map((issue) => ({ + issueId: issue.id, + priority: issue.correctPriority, + labels: issue.correctLabels, + })); + const round = scoreRound(submissions); + expect(round.accuracy).toBe(100); + expect(round.results).toHaveLength(TRIAGE_SCENARIOS.length); + }); + + it('updates and retrieves leaderboard', async () => { + const entry = await updateLeaderboard('player-test-1', 250, 3); + expect(entry.playerId).toBe('player-test-1'); + expect(entry.score).toBeGreaterThanOrEqual(250); + + const board = await getLeaderboard(); + expect(board.some((e) => e.playerId === 'player-test-1')).toBe(true); + }); +}); diff --git a/backend/tests/vulnerability-scanner.test.ts b/backend/tests/vulnerability-scanner.test.ts new file mode 100644 index 00000000..123a3c29 --- /dev/null +++ b/backend/tests/vulnerability-scanner.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from '@jest/globals'; +import { + scanContractSource, + validateScanRequest, +} from '../src/services/vulnerabilityScanner.service.js'; + +describe('Vulnerability Scanner Service', () => { + const vulnerable = `use std::collections::HashMap; +pub fn bad() { panic!("fail"); }`; + + it('validates scan input', () => { + expect(validateScanRequest('').valid).toBe(false); + expect(validateScanRequest(vulnerable).valid).toBe(true); + }); + + it('detects std imports and panic usage', () => { + const result = scanContractSource(vulnerable); + expect(result.findings.some((f) => f.rule === 'std-import')).toBe(true); + expect(result.findings.some((f) => f.rule === 'panic-usage')).toBe(true); + expect(result.score).toBeLessThan(100); + }); + + it('returns clean scan for safe code', () => { + const safe = `#![no_std] +#[contract] +pub struct Safe {}`; + const result = scanContractSource(safe); + expect(result.summary).toContain('No vulnerabilities'); + }); +}); diff --git a/docs/CURRICULUM_MODULE_FEATURES.md b/docs/CURRICULUM_MODULE_FEATURES.md new file mode 100644 index 00000000..5ea87fc8 --- /dev/null +++ b/docs/CURRICULUM_MODULE_FEATURES.md @@ -0,0 +1,38 @@ +# Curriculum Module Features + +Four MVP-critical features added across the Web3 Student Lab curriculum modules. + +## Routes + +| Feature | Module | Route | Backend API | +|---------|--------|-------|-------------| +| Gas Estimation Calculator | Open Source Contribution Trainer | `/open-source/gas-calculator` | `POST /api/v1/osct/gas-estimate` | +| Block Explorer Interface | Hackathon Project Idea Generator | `/hackathon-ideas/explorer` | `GET /api/v1/generator/explorer/snapshot` | +| Security Vulnerability Scanner | Blockchain Learning Simulator | `/simulator/scanner` | `POST /api/v1/simulator/scan` | +| Issue Triage Minigame | Smart Contract Playground | `/playground/triage` | `POST /api/v1/playground/triage/score` | + +## Architecture + +Each feature follows the **lib → hook → component → route** pattern (frontend) and **service → route → test** pattern (backend). + +## Tests + +```bash +# Frontend unit tests +cd frontend +npx vitest run src/lib/open-source/__tests__/gasCalculator.test.ts +npx vitest run src/lib/idea-generator/__tests__/blockExplorer.test.ts +npx vitest run src/lib/simulator/__tests__/vulnerabilityScanner.test.ts +npx vitest run src/lib/playground/__tests__/issueTriage.test.ts + +# Backend unit tests +cd backend +npm test -- --testPathPattern="gas-estimation|block-explorer|vulnerability-scanner|issue-triage" +``` + +## Tech Stack Alignment + +- **Gas Calculator**: Soroban resource estimation, OSCT budget presets, CI-compatible service layer +- **Block Explorer**: Stellar transaction feed, Redis-cached snapshots, hackathon idea suggestions +- **Vulnerability Scanner**: Static Soroban rule engine, severity scoring for educational feedback +- **Issue Triage**: XState minigame, Redis leaderboard via CacheService microservice pattern diff --git a/frontend/HACKATHON_IDEA_GENERATOR_README.md b/frontend/HACKATHON_IDEA_GENERATOR_README.md index f58e5f98..e6db1df5 100644 --- a/frontend/HACKATHON_IDEA_GENERATOR_README.md +++ b/frontend/HACKATHON_IDEA_GENERATOR_README.md @@ -10,6 +10,8 @@ Built with **React 19 + TypeScript**, styled to match the existing app. Route: `/hackathon-ideas` +Block Explorer sub-route: `/hackathon-ideas/explorer` + ## Architecture Follows the project's **data → derive → render** separation so each layer is diff --git a/frontend/src/app/hackathon-ideas/explorer/page.tsx b/frontend/src/app/hackathon-ideas/explorer/page.tsx new file mode 100644 index 00000000..cce5da59 --- /dev/null +++ b/frontend/src/app/hackathon-ideas/explorer/page.tsx @@ -0,0 +1,26 @@ +'use client'; + +import Link from 'next/link'; +import { ArrowLeft } from 'lucide-react'; +import BlockExplorerPanel from '@/components/idea-generator/BlockExplorerPanel'; + +export default function HackathonExplorerPage() { + return ( +
+ +
+

+ Explore live Stellar testnet activity while brainstorming hackathon project ideas. +

+ +
+
+ ); +} diff --git a/frontend/src/app/hackathon-ideas/page.tsx b/frontend/src/app/hackathon-ideas/page.tsx index 2b46e4e0..81983919 100644 --- a/frontend/src/app/hackathon-ideas/page.tsx +++ b/frontend/src/app/hackathon-ideas/page.tsx @@ -74,6 +74,14 @@ export default function HackathonIdeasPage() { animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, delay: 0.2 }} > +
+ + Open Block Explorer → + +
diff --git a/frontend/src/app/open-source/gas-calculator/page.tsx b/frontend/src/app/open-source/gas-calculator/page.tsx new file mode 100644 index 00000000..c9828340 --- /dev/null +++ b/frontend/src/app/open-source/gas-calculator/page.tsx @@ -0,0 +1,26 @@ +'use client'; + +import Link from 'next/link'; +import { ArrowLeft } from 'lucide-react'; +import GasCalculatorPanel from '@/components/open-source/GasCalculatorPanel'; + +export default function GasCalculatorPage() { + return ( +
+ +
+

+ Estimate Soroban gas before opening a PR — compare optimization strategies against contribution budgets. +

+ +
+
+ ); +} diff --git a/frontend/src/app/playground/triage/page.tsx b/frontend/src/app/playground/triage/page.tsx new file mode 100644 index 00000000..2bc6611d --- /dev/null +++ b/frontend/src/app/playground/triage/page.tsx @@ -0,0 +1,26 @@ +'use client'; + +import Link from 'next/link'; +import { ArrowLeft } from 'lucide-react'; +import IssueTriageMinigame from '@/components/playground/IssueTriageMinigame'; + +export default function IssueTriagePage() { + return ( +
+ +
+

+ Label and prioritize maintainer issues — practice open-source triage skills. +

+ +
+
+ ); +} diff --git a/frontend/src/app/simulator/scanner/page.tsx b/frontend/src/app/simulator/scanner/page.tsx new file mode 100644 index 00000000..e5bc224c --- /dev/null +++ b/frontend/src/app/simulator/scanner/page.tsx @@ -0,0 +1,26 @@ +'use client'; + +import Link from 'next/link'; +import { ArrowLeft } from 'lucide-react'; +import VulnerabilityScannerPanel from '@/components/simulator/VulnerabilityScannerPanel'; + +export default function VulnerabilityScannerPage() { + return ( +
+ +
+

+ Scan Soroban contract snippets for common vulnerability patterns in the learning simulator. +

+ +
+
+ ); +} diff --git a/frontend/src/components/idea-generator/BlockExplorerPanel.tsx b/frontend/src/components/idea-generator/BlockExplorerPanel.tsx new file mode 100644 index 00000000..0636510a --- /dev/null +++ b/frontend/src/components/idea-generator/BlockExplorerPanel.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { useHackathonBlockExplorer } from '@/hooks/useHackathonBlockExplorer'; +import { EXPLORER_OPERATIONS, buildStellarExpertLink } from '@/lib/idea-generator/blockExplorer'; + +function StatusBadge({ status }: { status: string }) { + const colors = + status === 'SUCCESS' + ? 'bg-green-500/10 text-green-400' + : status === 'FAILED' + ? 'bg-red-500/10 text-red-400' + : 'bg-yellow-500/10 text-yellow-400'; + return ( + {status} + ); +} + +export default function BlockExplorerPanel() { + const { + filteredTransactions, + filteredStats, + filter, + setFilter, + suggestedIdeas, + connectionStatus, + isLive, + toggleLive, + clearTransactions, + } = useHackathonBlockExplorer(); + + return ( +
+
+
+

Transactions

+

{filteredStats.totalTransactions}

+
+
+

Success Rate

+

{filteredStats.successRate}%

+
+
+

Avg Fee

+

{filteredStats.averageFee}

+
+
+

Latest Ledger

+

#{filteredStats.latestLedger}

+
+
+ +
+ setFilter((f) => ({ ...f, query: e.target.value }))} + placeholder="Search hash, account, operation…" + className="bg-background border-border-theme min-w-[200px] flex-1 rounded-lg border px-3 py-2 text-sm outline-none focus:border-red-500" + /> + + + + + {connectionStatus} +
+ +
+ + + + + + + + + + + + + {filteredTransactions.length === 0 ? ( + + + + ) : ( + filteredTransactions.slice(0, 30).map((tx) => ( + + + + + + + + + )) + )} + +
HashOperationAmountFeeLedgerStatus
+ No transactions match your filters. +
+ + {tx.hash.slice(0, 10)}… + + {tx.operation}{tx.amount} {tx.asset}{tx.fee}#{tx.ledger}
+
+ + {suggestedIdeas.length > 0 && ( + + )} +
+ ); +} diff --git a/frontend/src/components/open-source/GasCalculatorPanel.tsx b/frontend/src/components/open-source/GasCalculatorPanel.tsx new file mode 100644 index 00000000..286d65a9 --- /dev/null +++ b/frontend/src/components/open-source/GasCalculatorPanel.tsx @@ -0,0 +1,120 @@ +'use client'; + +import { useGasCalculator } from '@/hooks/useGasCalculator'; + +export default function GasCalculatorPanel() { + const { + sourceCode, + setSourceCode, + budgetPreset, + setBudgetPreset, + result, + error, + isCalculating, + estimate, + budgetOptions, + } = useGasCalculator(); + + return ( +
+
+
+ +