1650 lines
67 KiB
Python
1650 lines
67 KiB
Python
"""
|
|
Scam School API - Interactive Crypto Education Platform
|
|
=========================================================
|
|
Backend for the RMI Scam School: courses, lessons, progress tracking,
|
|
gamification (XP, levels, badges, streaks), certificates, and quizzes.
|
|
|
|
All data stored in Redis + Supabase backup.
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import secrets
|
|
from datetime import datetime
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, Request
|
|
from pydantic import BaseModel
|
|
|
|
from app.core.redis import get_redis
|
|
|
|
logger = logging.getLogger("scam_school")
|
|
router = APIRouter(tags=["scam-school"])
|
|
|
|
# ── Config ──
|
|
API_BASE = os.getenv("API_BASE", "https://rugmunch.io")
|
|
|
|
|
|
# ── Redis helper ──
|
|
async def get_current_user(request: Request) -> dict | None:
|
|
"""Extract user from JWT."""
|
|
from app.domains.auth import get_current_user as _get_user
|
|
|
|
return await _get_user(request)
|
|
|
|
|
|
# ── Course Data (embedded - no external dependencies) ──
|
|
|
|
COURSES = [
|
|
{
|
|
"id": "crypto-basics",
|
|
"title": "Crypto Basics",
|
|
"subtitle": "The foundation every investor needs",
|
|
"description": "Learn how blockchains work, what wallets are, how to read explorers, and why decentralization matters. No prior knowledge required.",
|
|
"difficulty": "beginner",
|
|
"duration_minutes": 45,
|
|
"lessons_count": 6,
|
|
"xp_reward": 500,
|
|
"badge_reward": "crypto-cadet",
|
|
"color": "#6366f1",
|
|
"icon": "shield",
|
|
"modules": [
|
|
{
|
|
"id": "what-is-blockchain",
|
|
"title": "What is a Blockchain?",
|
|
"type": "interactive",
|
|
"duration": 8,
|
|
"xp": 80,
|
|
"content": {
|
|
"slides": [
|
|
{
|
|
"type": "title",
|
|
"text": "The Digital Ledger",
|
|
"sub": "Understanding blockchain in 3 minutes",
|
|
},
|
|
{
|
|
"type": "interactive-block",
|
|
"title": "Build a Chain",
|
|
"description": "Drag blocks to form a chain. Each block contains transactions. Change one block and watch the chain break.",
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "What makes a blockchain immutable?",
|
|
"options": [
|
|
"It uses a database",
|
|
"Each block hashes the previous block",
|
|
"It is stored on one computer",
|
|
"It uses encryption",
|
|
],
|
|
"correct": 1,
|
|
"explanation": "Each block contains the hash of the previous block. Change one transaction and every subsequent hash changes, making tampering obvious.",
|
|
},
|
|
{
|
|
"type": "simulation",
|
|
"title": "Hash Visualization",
|
|
"description": "Type any text and see its SHA-256 hash change in real-time. Even one letter difference creates a completely different hash.",
|
|
},
|
|
]
|
|
},
|
|
},
|
|
{
|
|
"id": "wallets-explained",
|
|
"title": "Wallets & Keys",
|
|
"type": "interactive",
|
|
"duration": 7,
|
|
"xp": 70,
|
|
"content": {
|
|
"slides": [
|
|
{
|
|
"type": "title",
|
|
"text": "Your Crypto Wallet",
|
|
"sub": "Not actually a wallet",
|
|
},
|
|
{
|
|
"type": "interactive-keygen",
|
|
"title": "Generate a Key Pair",
|
|
"description": "Watch as a random private key is generated, and its corresponding public address is derived. Click to generate a new one.",
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "What should you NEVER do with your private key?",
|
|
"options": [
|
|
"Store it in a hardware wallet",
|
|
"Share it with anyone",
|
|
"Write it on paper",
|
|
"Use it to sign transactions",
|
|
],
|
|
"correct": 1,
|
|
"explanation": "Your private key is like the password to your entire account. Anyone with it controls your funds. NEVER share it, screenshot it, or paste it into websites.",
|
|
},
|
|
{
|
|
"type": "drag-drop",
|
|
"title": "Secure vs Insecure Storage",
|
|
"items": [
|
|
{"text": "Hardware wallet", "zone": "secure"},
|
|
{"text": "Screenshot on phone", "zone": "insecure"},
|
|
{"text": "Encrypted USB drive", "zone": "secure"},
|
|
{"text": "Email to yourself", "zone": "insecure"},
|
|
{"text": "Paper in safe", "zone": "secure"},
|
|
{"text": "Cloud storage", "zone": "insecure"},
|
|
],
|
|
},
|
|
]
|
|
},
|
|
},
|
|
{
|
|
"id": "reading-explorers",
|
|
"title": "Reading Block Explorers",
|
|
"type": "interactive",
|
|
"duration": 10,
|
|
"xp": 100,
|
|
"content": {
|
|
"slides": [
|
|
{
|
|
"type": "title",
|
|
"text": "Become a Blockchain Detective",
|
|
"sub": "How to read what really happened",
|
|
},
|
|
{
|
|
"type": "explorer-sim",
|
|
"title": "Live Explorer Simulation",
|
|
"description": "A simulated blockchain explorer showing real-looking transactions. Click through to trace a token from creation to first buyers.",
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "What does a 'contract creation' transaction mean?",
|
|
"options": [
|
|
"Someone bought a token",
|
|
"A new smart contract was deployed",
|
|
"The blockchain was upgraded",
|
|
"A wallet was created",
|
|
],
|
|
"correct": 1,
|
|
"explanation": "Contract creation means a developer deployed a new smart contract to the blockchain. This is how tokens, DEXs, and DeFi protocols are born.",
|
|
},
|
|
{
|
|
"type": "spot-the-difference",
|
|
"title": "Real vs Fake Explorer",
|
|
"description": "Two explorer screenshots side by side. One is real Etherscan, the other is a phishing clone. Spot 5 differences.",
|
|
},
|
|
]
|
|
},
|
|
},
|
|
{
|
|
"id": "tokens-vs-coins",
|
|
"title": "Coins vs Tokens",
|
|
"type": "lesson",
|
|
"duration": 5,
|
|
"xp": 50,
|
|
"content": {
|
|
"slides": [
|
|
{
|
|
"type": "title",
|
|
"text": "Not All Crypto is Equal",
|
|
"sub": "The difference between layer-1 coins and ERC-20 tokens",
|
|
},
|
|
{
|
|
"type": "comparison",
|
|
"left": {
|
|
"title": "Coin (Layer 1)",
|
|
"examples": ["Bitcoin", "Ethereum", "Solana"],
|
|
"traits": [
|
|
"Has its own blockchain",
|
|
"Used for network fees",
|
|
"Native to the protocol",
|
|
],
|
|
},
|
|
"right": {
|
|
"title": "Token",
|
|
"examples": ["USDC", "SHIB", "PEPE"],
|
|
"traits": [
|
|
"Built ON another blockchain",
|
|
"Uses smart contracts",
|
|
"Can be created by anyone",
|
|
],
|
|
},
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "Which can be created by anyone in 5 minutes?",
|
|
"options": [
|
|
"A new Bitcoin",
|
|
"A new Ethereum",
|
|
"A new token on Ethereum",
|
|
"A new Solana validator",
|
|
],
|
|
"correct": 2,
|
|
"explanation": "Anyone can deploy an ERC-20 token on Ethereum for under $5. This is why there are 500,000+ tokens and most are worthless or scams.",
|
|
},
|
|
]
|
|
},
|
|
},
|
|
{
|
|
"id": "gas-fees",
|
|
"title": "Gas Fees & Transactions",
|
|
"type": "interactive",
|
|
"duration": 8,
|
|
"xp": 80,
|
|
"content": {
|
|
"slides": [
|
|
{
|
|
"type": "title",
|
|
"text": "Why Transactions Cost Money",
|
|
"sub": "Gas: the fuel of the blockchain",
|
|
},
|
|
{
|
|
"type": "gas-simulator",
|
|
"title": "Gas Fee Simulator",
|
|
"description": "Adjust network congestion and transaction complexity. Watch how gas fees change in real-time. Compare Ethereum, Base, and Solana costs.",
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "What happens if you set gas too low?",
|
|
"options": [
|
|
"Transaction is free",
|
|
"Transaction fails or gets stuck",
|
|
"You get a refund",
|
|
"It processes faster",
|
|
],
|
|
"correct": 1,
|
|
"explanation": "If gas is too low, miners/validators won't include your transaction. It can get stuck 'pending' for hours or days until you cancel or replace it.",
|
|
},
|
|
{
|
|
"type": "interactive",
|
|
"title": "Estimate a Swap",
|
|
"description": "Simulate a token swap on Uniswap. Input amount, see price impact, slippage, and total gas cost before confirming.",
|
|
},
|
|
]
|
|
},
|
|
},
|
|
{
|
|
"id": "crypto-basics-exam",
|
|
"title": "Module Exam: Crypto Basics",
|
|
"type": "exam",
|
|
"duration": 7,
|
|
"xp": 120,
|
|
"passing_score": 80,
|
|
"content": {
|
|
"slides": [
|
|
{
|
|
"type": "exam-intro",
|
|
"text": "Prove your knowledge",
|
|
"sub": "12 questions · 80% to pass · 3 attempts allowed",
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "What is the primary purpose of a blockchain?",
|
|
"options": [
|
|
"To mine Bitcoin",
|
|
"To store decentralized, tamper-proof records",
|
|
"To replace banks",
|
|
"To create NFTs",
|
|
],
|
|
"correct": 1,
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "Which of these is a Layer-1 blockchain?",
|
|
"options": ["USDC", "Uniswap", "Ethereum", "Chainlink"],
|
|
"correct": 2,
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "What does 'immutable' mean in blockchain?",
|
|
"options": [
|
|
"Fast transactions",
|
|
"Cannot be changed after creation",
|
|
"Free to use",
|
|
"Anonymous",
|
|
],
|
|
"correct": 1,
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "Where is the SAFEST place to store large amounts of crypto?",
|
|
"options": [
|
|
"Exchange wallet",
|
|
"Mobile app",
|
|
"Hardware wallet",
|
|
"Browser extension",
|
|
],
|
|
"correct": 2,
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "What is a 'private key'?",
|
|
"options": [
|
|
"Your wallet address",
|
|
"Your password to the blockchain",
|
|
"A type of cryptocurrency",
|
|
"A mining tool",
|
|
],
|
|
"correct": 1,
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "What happens to gas fees when the network is congested?",
|
|
"options": [
|
|
"They decrease",
|
|
"They increase",
|
|
"They stay the same",
|
|
"They become free",
|
|
],
|
|
"correct": 1,
|
|
},
|
|
]
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"id": "scam-detection",
|
|
"title": "Scam Detection Masterclass",
|
|
"subtitle": "Learn to spot rugs before they happen",
|
|
"description": "The core RMI curriculum. Learn every red flag, from honeypots to coordinated bundling. Interactive simulations of real scam patterns.",
|
|
"difficulty": "intermediate",
|
|
"duration_minutes": 90,
|
|
"lessons_count": 10,
|
|
"xp_reward": 1200,
|
|
"badge_reward": "scam-hunter",
|
|
"color": "#ef4444",
|
|
"icon": "siren",
|
|
"modules": [
|
|
{
|
|
"id": "honeypots",
|
|
"title": "Honeypot Traps",
|
|
"type": "interactive",
|
|
"duration": 12,
|
|
"xp": 150,
|
|
"content": {
|
|
"slides": [
|
|
{
|
|
"type": "title",
|
|
"text": "The Honeypot Trap",
|
|
"sub": "You can buy, but you can never sell",
|
|
},
|
|
{
|
|
"type": "honeypot-sim",
|
|
"title": "Honeypot Simulator",
|
|
"description": "A live trading simulation. You 'buy' a token, the price goes up. Try to sell... the button is disabled. Watch how the contract code prevents sells.",
|
|
},
|
|
{
|
|
"type": "code-inspection",
|
|
"title": "Spot the Honeypot Code",
|
|
"description": "Two smart contract code snippets. One is legitimate, one contains a honeypot. Find the malicious line.",
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "What is a honeypot token?",
|
|
"options": [
|
|
"A token with high rewards",
|
|
"A token you can buy but not sell",
|
|
"A token with locked liquidity",
|
|
"A token with a cute mascot",
|
|
],
|
|
"correct": 1,
|
|
"explanation": "Honeypots allow buys (attracting victims) but block sells through smart contract code. Your money goes in, never comes out.",
|
|
},
|
|
{
|
|
"type": "interactive",
|
|
"title": "RMI Scanner Demo",
|
|
"description": "Paste a contract address (simulated) and watch the RMI scanner detect honeypot code, unverified contracts, and malicious functions in real-time.",
|
|
},
|
|
]
|
|
},
|
|
},
|
|
{
|
|
"id": "rug-pull-mechanics",
|
|
"title": "Rug Pull Mechanics",
|
|
"type": "interactive",
|
|
"duration": 15,
|
|
"xp": 200,
|
|
"content": {
|
|
"slides": [
|
|
{
|
|
"type": "title",
|
|
"text": "The Anatomy of a Rug Pull",
|
|
"sub": "From launch to drain in 48 hours",
|
|
},
|
|
{
|
|
"type": "timeline",
|
|
"title": "Rug Pull Timeline",
|
|
"events": [
|
|
{
|
|
"time": "T-7 days",
|
|
"event": "Dev creates token, socials, and website",
|
|
"flag": False,
|
|
},
|
|
{
|
|
"time": "T-1 day",
|
|
"event": "Influencers paid to shill",
|
|
"flag": True,
|
|
},
|
|
{
|
|
"time": "T-0",
|
|
"event": "Token launches with 80% insider allocation",
|
|
"flag": True,
|
|
},
|
|
{
|
|
"time": "T+2 hours",
|
|
"event": "Price pumps 10x as insiders coordinate buys",
|
|
"flag": True,
|
|
},
|
|
{
|
|
"time": "T+6 hours",
|
|
"event": "Insiders dump 60% of supply",
|
|
"flag": True,
|
|
},
|
|
{
|
|
"time": "T+12 hours",
|
|
"event": "LP tokens withdrawn, liquidity drained",
|
|
"flag": True,
|
|
},
|
|
{
|
|
"time": "T+48 hours",
|
|
"event": "Socials deleted, website down, dev gone",
|
|
"flag": True,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"type": "allocation-sim",
|
|
"title": "Token Allocation Simulator",
|
|
"description": "Design a token allocation. If the team holds more than 20%, the community is at risk. Watch what happens when insiders control 80%.",
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "What is the FIRST red flag in a rug pull timeline?",
|
|
"options": [
|
|
"Price drops",
|
|
"High insider allocation at launch",
|
|
"Social media deleted",
|
|
"Website goes down",
|
|
],
|
|
"correct": 1,
|
|
"explanation": "The allocation is visible BEFORE you invest. If insiders hold 60-80%, they have the power to dump on you. Check this first.",
|
|
},
|
|
{
|
|
"type": "interactive",
|
|
"title": "Liquidity Lock Check",
|
|
"description": "Simulate checking LP tokens on a lock platform. Unlocked LP = dev can drain the pool anytime. Locked LP = safer (but not guaranteed).",
|
|
},
|
|
]
|
|
},
|
|
},
|
|
{
|
|
"id": "bundler-detection",
|
|
"title": "Bundler & Sniper Detection",
|
|
"type": "interactive",
|
|
"duration": 15,
|
|
"xp": 200,
|
|
"content": {
|
|
"slides": [
|
|
{
|
|
"type": "title",
|
|
"text": "The Coordinated Attack",
|
|
"sub": "How bundlers manipulate launches",
|
|
},
|
|
{
|
|
"type": "bundle-visualizer",
|
|
"title": "Bundle Visualizer",
|
|
"description": "Watch 20 connected wallets buy simultaneously at launch. The visualization shows wallet connections, buy timing, and total allocation. This is a coordinated bundler attack.",
|
|
},
|
|
{
|
|
"type": "sniper-sim",
|
|
"title": "Sniper Bot Simulation",
|
|
"description": "Simulate a token launch. Bots buy in the same block as creation. Normal users buy 10 blocks later at 5x the price. See the unfair advantage in real-time.",
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "What is a 'bundler' in crypto?",
|
|
"options": [
|
|
"A wallet that holds many tokens",
|
|
"A group of coordinated wallets buying together",
|
|
"A type of smart contract",
|
|
"A blockchain validator",
|
|
],
|
|
"correct": 1,
|
|
"explanation": "Bundlers are networks of wallets controlled by the same entity. They buy simultaneously to hide their total allocation and create fake demand.",
|
|
},
|
|
{
|
|
"type": "interactive",
|
|
"title": "Wallet Cluster Analysis",
|
|
"description": "Given 10 wallet addresses, trace their funding sources. 8 were funded by the same wallet 1 hour before launch. This is the bundler's master wallet.",
|
|
},
|
|
]
|
|
},
|
|
},
|
|
{
|
|
"id": "contract-red-flags",
|
|
"title": "Smart Contract Red Flags",
|
|
"type": "interactive",
|
|
"duration": 12,
|
|
"xp": 150,
|
|
"content": {
|
|
"slides": [
|
|
{
|
|
"type": "title",
|
|
"text": "Reading the Code",
|
|
"sub": "Red flags in smart contracts",
|
|
},
|
|
{
|
|
"type": "code-hunt",
|
|
"title": "Find the Kill Switch",
|
|
"description": "A smart contract has a hidden 'kill' function that lets the owner destroy all tokens. Find it in the code.",
|
|
},
|
|
{
|
|
"type": "red-flag-checklist",
|
|
"title": "Contract Red Flag Checklist",
|
|
"items": [
|
|
{
|
|
"flag": "Mint function not renounced",
|
|
"risk": "Dev can create infinite tokens",
|
|
},
|
|
{
|
|
"flag": "Ownership not renounced",
|
|
"risk": "Dev can change contract rules",
|
|
},
|
|
{
|
|
"flag": "Hidden transfer fees > 10%",
|
|
"risk": "Every buy/sell loses money to dev",
|
|
},
|
|
{
|
|
"flag": "Blacklist function exists",
|
|
"risk": "Dev can block you from selling",
|
|
},
|
|
{
|
|
"flag": "Contract not verified",
|
|
"risk": "You can't see what the code does",
|
|
},
|
|
{
|
|
"flag": "Proxy contract with admin",
|
|
"risk": "Dev can change code after launch",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "Why is an unverified contract dangerous?",
|
|
"options": [
|
|
"It costs more gas",
|
|
"You can't read the source code",
|
|
"It has no logo",
|
|
"It can't be traded",
|
|
],
|
|
"correct": 1,
|
|
"explanation": "Unverified contracts hide their source code. The deployer could have put anything in there - honeypots, backdoors, infinite mint functions. Always verify.",
|
|
},
|
|
]
|
|
},
|
|
},
|
|
{
|
|
"id": "social-engineering",
|
|
"title": "Social Engineering Attacks",
|
|
"type": "interactive",
|
|
"duration": 10,
|
|
"xp": 120,
|
|
"content": {
|
|
"slides": [
|
|
{
|
|
"type": "title",
|
|
"text": "The Human Exploit",
|
|
"sub": "When scammers hack your mind, not your wallet",
|
|
},
|
|
{
|
|
"type": "phishing-sim",
|
|
"title": "Phishing Simulation",
|
|
"description": "You receive a DM: 'Your wallet has been compromised. Click here to secure it immediately.' The link looks like MetaMask. What do you do? Interactive choose-your-own-adventure.",
|
|
},
|
|
{
|
|
"type": "fake-support",
|
|
"title": "Fake Support Scam",
|
|
"description": "A 'support agent' in Discord asks you to 'verify your wallet' by sharing your seed phrase. Walk through the conversation and spot the red flags.",
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "A 'dev' DMs you with a 'pre-sale' link. What do you do?",
|
|
"options": [
|
|
"Click it immediately",
|
|
"Ask for proof they're the real dev",
|
|
"Check official channels and ignore DMs",
|
|
"Send a small amount to test",
|
|
],
|
|
"correct": 2,
|
|
"explanation": "Real devs never DM first. Always verify through official channels (verified Twitter, official Discord announcements). Pre-sale links in DMs are 99% scams.",
|
|
},
|
|
{
|
|
"type": "drag-drop",
|
|
"title": "Real vs Fake Announcement",
|
|
"items": [
|
|
{
|
|
"text": "Posted by verified account with checkmark",
|
|
"zone": "real",
|
|
},
|
|
{"text": "Urgency: 'Limited time, act NOW!'", "zone": "fake"},
|
|
{"text": "Links to official domain", "zone": "real"},
|
|
{"text": "Asks for private key or seed phrase", "zone": "fake"},
|
|
{"text": "Announced on multiple official channels", "zone": "real"},
|
|
{"text": "Only shared in DM", "zone": "fake"},
|
|
],
|
|
},
|
|
]
|
|
},
|
|
},
|
|
{
|
|
"id": "pump-dump-patterns",
|
|
"title": "Pump & Dump Patterns",
|
|
"type": "interactive",
|
|
"duration": 10,
|
|
"xp": 120,
|
|
"content": {
|
|
"slides": [
|
|
{
|
|
"type": "title",
|
|
"text": "The Artificial Hype",
|
|
"sub": "How coordinated pumps create bagholders",
|
|
},
|
|
{
|
|
"type": "chart-sim",
|
|
"title": "Live Chart Simulation",
|
|
"description": "Watch a token chart in real-time. See the telltale signs: volume spikes with no news, identical buy sizes, wallet clustering, and the inevitable dump.",
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "What is the hallmark of a coordinated pump?",
|
|
"options": [
|
|
"Steady organic growth",
|
|
"Sudden volume spike with identical wallet sizes",
|
|
"Slow price decline",
|
|
"High liquidity",
|
|
],
|
|
"correct": 1,
|
|
"explanation": "Coordinated pumps show sudden volume spikes from many wallets of similar size buying at the same time. This is bots or a group acting together.",
|
|
},
|
|
{
|
|
"type": "interactive",
|
|
"title": "Volume Analysis Tool",
|
|
"description": "Analyze a token's trading history. Spot the wash trading: buyers and sellers are the same wallets. Real volume vs fake volume visualization.",
|
|
},
|
|
]
|
|
},
|
|
},
|
|
{
|
|
"id": "scam-detection-exam",
|
|
"title": "Final Exam: Scam Detection",
|
|
"type": "exam",
|
|
"duration": 16,
|
|
"xp": 260,
|
|
"passing_score": 85,
|
|
"content": {
|
|
"slides": [
|
|
{
|
|
"type": "exam-intro",
|
|
"text": "The Scam Hunter Certification",
|
|
"sub": "20 questions · 85% to pass · Certificate awarded",
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "A token has 70% insider allocation. What is the risk?",
|
|
"options": [
|
|
"Low risk, insiders are trustworthy",
|
|
"High risk, insiders can dump on retail",
|
|
"No risk, allocation doesn't matter",
|
|
"Medium risk, only if price drops",
|
|
],
|
|
"correct": 1,
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "What does 'LP not burned' mean?",
|
|
"options": [
|
|
"Liquidity is locked forever",
|
|
"Dev can withdraw liquidity anytime",
|
|
"Tokens can't be traded",
|
|
"The contract is verified",
|
|
],
|
|
"correct": 1,
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "You see 15 wallets funded by the same address buying simultaneously. This is:",
|
|
"options": [
|
|
"Organic demand",
|
|
"A bundler attack",
|
|
"Market making",
|
|
"Staking rewards",
|
|
],
|
|
"correct": 1,
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "A contract has a 'blacklist' function. What can the dev do?",
|
|
"options": [
|
|
"Block specific wallets from selling",
|
|
"Increase token supply",
|
|
"Change the token name",
|
|
"Burn tokens",
|
|
],
|
|
"correct": 0,
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "Which is the SAFEST action when receiving an unsolicited airdrop?",
|
|
"options": [
|
|
"Sell it immediately",
|
|
"Interact with the token's website",
|
|
"Ignore it and don't interact",
|
|
"Stake it for rewards",
|
|
],
|
|
"correct": 2,
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "What is 'slippage' in a DEX trade?",
|
|
"options": [
|
|
"The trading fee",
|
|
"The difference between expected and actual price",
|
|
"The gas cost",
|
|
"The time to confirm",
|
|
],
|
|
"correct": 1,
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "A token's website claims 'audited by CertiK' but CertiK has no record. This is:",
|
|
"options": [
|
|
"A minor mistake",
|
|
"A fake audit claim",
|
|
"An old audit",
|
|
"A private audit",
|
|
],
|
|
"correct": 1,
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "What is the #1 rule of crypto safety?",
|
|
"options": [
|
|
"Always follow influencers",
|
|
"Never invest more than you can afford to lose",
|
|
"Buy the dip",
|
|
"Hold forever",
|
|
],
|
|
"correct": 1,
|
|
},
|
|
]
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"id": "advanced-forensics",
|
|
"title": "Advanced On-Chain Forensics",
|
|
"subtitle": "Think like a blockchain detective",
|
|
"description": "Deep technical analysis. Trace transactions across chains, decode contract interactions, and build cases with real evidence.",
|
|
"difficulty": "advanced",
|
|
"duration_minutes": 120,
|
|
"lessons_count": 8,
|
|
"xp_reward": 2000,
|
|
"badge_reward": "forensic-analyst",
|
|
"color": "#f59e0b",
|
|
"icon": "microscope",
|
|
"modules": [
|
|
{
|
|
"id": "tracing-transactions",
|
|
"title": "Tracing Transactions",
|
|
"type": "interactive",
|
|
"duration": 15,
|
|
"xp": 250,
|
|
"content": {
|
|
"slides": [
|
|
{
|
|
"type": "title",
|
|
"text": "Follow the Money",
|
|
"sub": "Every transaction leaves a trail",
|
|
},
|
|
{
|
|
"type": "trace-sim",
|
|
"title": "Transaction Tracer",
|
|
"description": "Start with a suspicious wallet. Trace its funding source through 5 hops. Discover it was funded by a known scammer wallet from a previous rug pull.",
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "What tool shows all transactions for a specific wallet?",
|
|
"options": [
|
|
"DEX Screener",
|
|
"Etherscan / Solscan",
|
|
"CoinMarketCap",
|
|
"Twitter",
|
|
],
|
|
"correct": 1,
|
|
"explanation": "Block explorers like Etherscan (Ethereum) and Solscan (Solana) show every transaction, token transfer, and contract interaction for any wallet.",
|
|
},
|
|
{
|
|
"type": "interactive",
|
|
"title": "Multi-Chain Trace",
|
|
"description": "A scammer bridges stolen funds from Ethereum to Base to Solana. Follow the trail across chains using bridge transaction hashes.",
|
|
},
|
|
]
|
|
},
|
|
},
|
|
{
|
|
"id": "contract-forensics",
|
|
"title": "Contract Forensics",
|
|
"type": "interactive",
|
|
"duration": 20,
|
|
"xp": 300,
|
|
"content": {
|
|
"slides": [
|
|
{
|
|
"type": "title",
|
|
"text": "Decode the Contract",
|
|
"sub": "Reading bytecode like a detective reads fingerprints",
|
|
},
|
|
{
|
|
"type": "bytecode-sim",
|
|
"title": "Bytecode Analysis",
|
|
"description": "Even unverified contracts can be analyzed. Use decompilers to find function signatures, storage patterns, and suspicious logic in raw bytecode.",
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "What does 'renounced ownership' mean?",
|
|
"options": [
|
|
"The dev sold their tokens",
|
|
"No one can change the contract anymore",
|
|
"The contract was deleted",
|
|
"The token is worthless",
|
|
],
|
|
"correct": 1,
|
|
"explanation": "Renounced ownership means the deployer sent ownership to the zero address (0x000...). They can no longer mint tokens, change fees, or pause trading.",
|
|
},
|
|
{
|
|
"type": "interactive",
|
|
"title": "Function Signature Decoder",
|
|
"description": "Given a transaction's input data (0x23b872dd...), decode it to 'transferFrom(address,address,uint256)'. Learn to read raw transaction data.",
|
|
},
|
|
]
|
|
},
|
|
},
|
|
{
|
|
"id": "advanced-exam",
|
|
"title": "Master Forensics Exam",
|
|
"type": "exam",
|
|
"duration": 20,
|
|
"xp": 450,
|
|
"passing_score": 90,
|
|
"content": {
|
|
"slides": [
|
|
{
|
|
"type": "exam-intro",
|
|
"text": "Forensic Analyst Certification",
|
|
"sub": "15 questions · 90% to pass · Elite badge awarded",
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "How can you detect wash trading?",
|
|
"options": [
|
|
"High market cap",
|
|
"Same wallets buying and selling to each other",
|
|
"Low gas fees",
|
|
"Verified contract",
|
|
],
|
|
"correct": 1,
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "What is a 'dust attack'?",
|
|
"options": [
|
|
"Sending tiny amounts to track wallets",
|
|
"A type of DDoS",
|
|
"Mining with low power",
|
|
"A phishing technique",
|
|
],
|
|
"correct": 0,
|
|
},
|
|
{
|
|
"type": "quiz",
|
|
"question": "Which bridge transaction field is MOST important for tracing?",
|
|
"options": [
|
|
"Token symbol",
|
|
"Transaction hash",
|
|
"Wallet balance",
|
|
"Gas price",
|
|
],
|
|
"correct": 1,
|
|
},
|
|
]
|
|
},
|
|
},
|
|
],
|
|
},
|
|
]
|
|
|
|
# ── Gamification Config ──
|
|
|
|
LEVELS = [
|
|
{"level": 1, "title": "Novice", "xp_required": 0, "color": "#9ca3af"},
|
|
{"level": 2, "title": "Learner", "xp_required": 300, "color": "#6366f1"},
|
|
{"level": 3, "title": "Analyst", "xp_required": 800, "color": "#8b5cf6"},
|
|
{"level": 4, "title": "Detective", "xp_required": 1500, "color": "#ec4899"},
|
|
{"level": 5, "title": "Hunter", "xp_required": 2500, "color": "#f59e0b"},
|
|
{"level": 6, "title": "Expert", "xp_required": 4000, "color": "#ef4444"},
|
|
{"level": 7, "title": "Master", "xp_required": 6000, "color": "#10b981"},
|
|
{"level": 8, "title": "Legend", "xp_required": 9000, "color": "#3b82f6"},
|
|
{"level": 9, "title": "Oracle", "xp_required": 13000, "color": "#fbbf24"},
|
|
{"level": 10, "title": "Sentinel", "xp_required": 18000, "color": "#dc2626"},
|
|
]
|
|
|
|
BADGES = {
|
|
"crypto-cadet": {
|
|
"name": "Crypto Cadet",
|
|
"description": "Completed Crypto Basics",
|
|
"icon": "shield",
|
|
"color": "#6366f1",
|
|
"rarity": "common",
|
|
},
|
|
"scam-hunter": {
|
|
"name": "Scam Hunter",
|
|
"description": "Completed Scam Detection Masterclass",
|
|
"icon": "crosshair",
|
|
"color": "#ef4444",
|
|
"rarity": "rare",
|
|
},
|
|
"forensic-analyst": {
|
|
"name": "Forensic Analyst",
|
|
"description": "Completed Advanced Forensics",
|
|
"icon": "microscope",
|
|
"color": "#f59e0b",
|
|
"rarity": "epic",
|
|
},
|
|
"perfect-score": {
|
|
"name": "Perfect Score",
|
|
"description": "100% on any exam",
|
|
"icon": "star",
|
|
"color": "#fbbf24",
|
|
"rarity": "legendary",
|
|
},
|
|
"speed-learner": {
|
|
"name": "Speed Learner",
|
|
"description": "Completed 3 lessons in one day",
|
|
"icon": "zap",
|
|
"color": "#10b981",
|
|
"rarity": "rare",
|
|
},
|
|
"streak-master": {
|
|
"name": "Streak Master",
|
|
"description": "7-day learning streak",
|
|
"icon": "flame",
|
|
"color": "#f97316",
|
|
"rarity": "epic",
|
|
},
|
|
"community-guardian": {
|
|
"name": "Community Guardian",
|
|
"description": "Reported 5 verified scams",
|
|
"icon": "users",
|
|
"color": "#3b82f6",
|
|
"rarity": "legendary",
|
|
},
|
|
"first-blood": {
|
|
"name": "First Blood",
|
|
"description": "First lesson completed",
|
|
"icon": "target",
|
|
"color": "#ec4899",
|
|
"rarity": "common",
|
|
},
|
|
}
|
|
|
|
ACHIEVEMENTS = [
|
|
{
|
|
"id": "first-lesson",
|
|
"name": "First Steps",
|
|
"description": "Complete your first lesson",
|
|
"xp": 50,
|
|
"icon": "footprints",
|
|
},
|
|
{
|
|
"id": "first-course",
|
|
"name": "Course Complete",
|
|
"description": "Complete your first course",
|
|
"xp": 200,
|
|
"icon": "graduation-cap",
|
|
},
|
|
{
|
|
"id": "exam-pass",
|
|
"name": "Exam Passed",
|
|
"description": "Pass any exam with 80%+",
|
|
"xp": 150,
|
|
"icon": "check-circle",
|
|
},
|
|
{
|
|
"id": "perfect-exam",
|
|
"name": "Flawless",
|
|
"description": "Get 100% on an exam",
|
|
"xp": 500,
|
|
"icon": "crown",
|
|
},
|
|
{
|
|
"id": "streak-3",
|
|
"name": "On Fire",
|
|
"description": "3-day learning streak",
|
|
"xp": 100,
|
|
"icon": "flame",
|
|
},
|
|
{
|
|
"id": "streak-7",
|
|
"name": "Unstoppable",
|
|
"description": "7-day learning streak",
|
|
"xp": 300,
|
|
"icon": "fire",
|
|
},
|
|
{
|
|
"id": "streak-30",
|
|
"name": "Dedicated",
|
|
"description": "30-day learning streak",
|
|
"xp": 1000,
|
|
"icon": "award",
|
|
},
|
|
{
|
|
"id": "all-courses",
|
|
"name": "Scholar",
|
|
"description": "Complete all courses",
|
|
"xp": 2000,
|
|
"icon": "book-open",
|
|
},
|
|
{
|
|
"id": "help-others",
|
|
"name": "Mentor",
|
|
"description": "Help 3 other learners in community",
|
|
"xp": 200,
|
|
"icon": "heart",
|
|
},
|
|
{
|
|
"id": "report-scam",
|
|
"name": "Whistleblower",
|
|
"description": "Report a verified scam",
|
|
"xp": 300,
|
|
"icon": "megaphone",
|
|
},
|
|
]
|
|
|
|
# ── Models ──
|
|
|
|
|
|
class LessonProgress(BaseModel):
|
|
lesson_id: str
|
|
course_id: str
|
|
completed: bool = False
|
|
score: int | None = None
|
|
xp_earned: int = 0
|
|
time_spent_seconds: int = 0
|
|
completed_at: str | None = None
|
|
attempts: int = 0
|
|
|
|
|
|
class CourseProgress(BaseModel):
|
|
course_id: str
|
|
completed_lessons: list[str] = []
|
|
total_lessons: int
|
|
exam_score: int | None = None
|
|
exam_passed: bool = False
|
|
started_at: str
|
|
completed_at: str | None = None
|
|
|
|
|
|
class UserProgress(BaseModel):
|
|
user_id: str
|
|
total_xp: int = 0
|
|
level: int = 1
|
|
level_title: str = "Novice"
|
|
streak_days: int = 0
|
|
longest_streak: int = 0
|
|
last_activity: str | None = None
|
|
courses_progress: dict[str, CourseProgress] = {}
|
|
lessons_progress: dict[str, LessonProgress] = {}
|
|
badges: list[str] = []
|
|
achievements: list[str] = []
|
|
certificates: list[dict] = []
|
|
time_spent_total_minutes: int = 0
|
|
|
|
|
|
class QuizAnswer(BaseModel):
|
|
question_index: int
|
|
answer: int
|
|
|
|
|
|
class ExamSubmit(BaseModel):
|
|
answers: list[QuizAnswer]
|
|
time_spent_seconds: int = 0
|
|
|
|
|
|
class LessonComplete(BaseModel):
|
|
lesson_id: str
|
|
course_id: str
|
|
score: int | None = None
|
|
time_spent_seconds: int = 0
|
|
answers: list[QuizAnswer] | None = None
|
|
|
|
|
|
# ── Helpers ──
|
|
|
|
|
|
def _get_progress(user_id: str) -> UserProgress:
|
|
"""Get or create user progress."""
|
|
r = get_redis()
|
|
data = r.hget("rmi:scam_school:progress", user_id)
|
|
if data:
|
|
d = json.loads(data)
|
|
return UserProgress(**d)
|
|
return UserProgress(user_id=user_id, started_at=datetime.utcnow().isoformat())
|
|
|
|
|
|
def _save_progress(progress: UserProgress):
|
|
"""Save user progress to Redis."""
|
|
r = get_redis()
|
|
r.hset("rmi:scam_school:progress", progress.user_id, progress.model_dump_json())
|
|
|
|
|
|
def _calculate_level(xp: int) -> dict:
|
|
"""Calculate level from XP."""
|
|
current_level = LEVELS[0]
|
|
next_level = LEVELS[1] if len(LEVELS) > 1 else None
|
|
for i, level in enumerate(LEVELS):
|
|
if xp >= level["xp_required"]:
|
|
current_level = level
|
|
next_level = LEVELS[i + 1] if i + 1 < len(LEVELS) else None
|
|
progress_pct = 0
|
|
if next_level:
|
|
range_size = next_level["xp_required"] - current_level["xp_required"]
|
|
progress_in_range = xp - current_level["xp_required"]
|
|
progress_pct = min(100, int((progress_in_range / range_size) * 100)) if range_size > 0 else 100
|
|
return {
|
|
"level": current_level["level"],
|
|
"title": current_level["title"],
|
|
"color": current_level["color"],
|
|
"xp": xp,
|
|
"xp_to_next": next_level["xp_required"] - xp if next_level else 0,
|
|
"next_level_title": next_level["title"] if next_level else None,
|
|
"progress_pct": progress_pct,
|
|
}
|
|
|
|
|
|
def _award_xp(progress: UserProgress, amount: int, source: str) -> dict:
|
|
"""Award XP and check for level ups."""
|
|
old_level = progress.level
|
|
progress.total_xp += amount
|
|
new_level_data = _calculate_level(progress.total_xp)
|
|
progress.level = new_level_data["level"]
|
|
progress.level_title = new_level_data["title"]
|
|
|
|
leveled_up = progress.level > old_level
|
|
return {
|
|
"awarded": amount,
|
|
"source": source,
|
|
"new_total": progress.total_xp,
|
|
"leveled_up": leveled_up,
|
|
"old_level": old_level,
|
|
"new_level": progress.level,
|
|
"level_title": progress.level_title,
|
|
}
|
|
|
|
|
|
def _award_badge(progress: UserProgress, badge_id: str) -> dict | None:
|
|
"""Award a badge if not already owned."""
|
|
if badge_id in progress.badges:
|
|
return None
|
|
if badge_id not in BADGES:
|
|
return None
|
|
progress.badges.append(badge_id)
|
|
badge = BADGES[badge_id]
|
|
return {
|
|
"badge_id": badge_id,
|
|
"name": badge["name"],
|
|
"description": badge["description"],
|
|
"icon": badge["icon"],
|
|
"color": badge["color"],
|
|
"rarity": badge["rarity"],
|
|
}
|
|
|
|
|
|
def _check_achievements(progress: UserProgress) -> list[dict]:
|
|
"""Check and award new achievements."""
|
|
new_achievements = []
|
|
|
|
# First lesson
|
|
if "first-lesson" not in progress.achievements and len(progress.lessons_progress) > 0:
|
|
if any(line.completed for line in progress.lessons_progress.values()):
|
|
progress.achievements.append("first-lesson")
|
|
new_achievements.append({"id": "first-lesson", "name": "First Steps", "xp": 50})
|
|
|
|
# First course
|
|
if "first-course" not in progress.achievements:
|
|
completed_courses = sum(1 for c in progress.courses_progress.values() if c.completed_at)
|
|
if completed_courses >= 1:
|
|
progress.achievements.append("first-course")
|
|
new_achievements.append({"id": "first-course", "name": "Course Complete", "xp": 200})
|
|
|
|
# All courses
|
|
if "all-courses" not in progress.achievements:
|
|
if len(progress.courses_progress) >= len(COURSES):
|
|
all_done = all(c.completed_at for c in progress.courses_progress.values())
|
|
if all_done:
|
|
progress.achievements.append("all-courses")
|
|
new_achievements.append({"id": "all-courses", "name": "Scholar", "xp": 2000})
|
|
|
|
# Streak achievements
|
|
if "streak-3" not in progress.achievements and progress.streak_days >= 3:
|
|
progress.achievements.append("streak-3")
|
|
new_achievements.append({"id": "streak-3", "name": "On Fire", "xp": 100})
|
|
if "streak-7" not in progress.achievements and progress.streak_days >= 7:
|
|
progress.achievements.append("streak-7")
|
|
new_achievements.append({"id": "streak-7", "name": "Unstoppable", "xp": 300})
|
|
_award_badge(progress, "streak-master")
|
|
if "streak-30" not in progress.achievements and progress.streak_days >= 30:
|
|
progress.achievements.append("streak-30")
|
|
new_achievements.append({"id": "streak-30", "name": "Dedicated", "xp": 1000})
|
|
|
|
return new_achievements
|
|
|
|
|
|
def _update_streak(progress: UserProgress):
|
|
"""Update learning streak."""
|
|
now = datetime.utcnow()
|
|
if progress.last_activity:
|
|
last = datetime.fromisoformat(progress.last_activity)
|
|
days_diff = (now - last).days
|
|
if days_diff == 0:
|
|
return # Already active today
|
|
elif days_diff == 1:
|
|
progress.streak_days += 1
|
|
else:
|
|
progress.streak_days = 1 # Reset
|
|
progress.longest_streak = max(progress.longest_streak, progress.streak_days)
|
|
else:
|
|
progress.streak_days = 1
|
|
progress.last_activity = now.isoformat()
|
|
|
|
|
|
def _generate_certificate(user_id: str, course_id: str, score: int) -> dict:
|
|
"""Generate a certificate."""
|
|
cert_id = f"cert-{course_id}-{user_id[:8]}-{secrets.token_hex(4)}"
|
|
course = next((c for c in COURSES if c["id"] == course_id), None)
|
|
return {
|
|
"id": cert_id,
|
|
"course_id": course_id,
|
|
"course_title": course["title"] if course else "Unknown Course",
|
|
"user_id": user_id,
|
|
"score": score,
|
|
"issued_at": datetime.utcnow().isoformat(),
|
|
"verify_url": f"{API_BASE}/verify-cert/{cert_id}",
|
|
"hash": hashlib.sha256(f"{cert_id}:{user_id}:{course_id}".encode()).hexdigest()[:16],
|
|
}
|
|
|
|
|
|
# ── Endpoints ──
|
|
|
|
|
|
@router.get("/scam-school/courses")
|
|
async def list_courses():
|
|
"""List all available courses with metadata."""
|
|
return {
|
|
"courses": [
|
|
{
|
|
"id": c["id"],
|
|
"title": c["title"],
|
|
"subtitle": c["subtitle"],
|
|
"description": c["description"],
|
|
"difficulty": c["difficulty"],
|
|
"duration_minutes": c["duration_minutes"],
|
|
"lessons_count": c["lessons_count"],
|
|
"xp_reward": c["xp_reward"],
|
|
"badge_reward": c["badge_reward"],
|
|
"color": c["color"],
|
|
"icon": c["icon"],
|
|
"modules": [
|
|
{
|
|
"id": m["id"],
|
|
"title": m["title"],
|
|
"type": m["type"],
|
|
"duration": m["duration"],
|
|
"xp": m["xp"],
|
|
}
|
|
for m in c["modules"]
|
|
],
|
|
}
|
|
for c in COURSES
|
|
]
|
|
}
|
|
|
|
|
|
@router.get("/scam-school/courses/{course_id}")
|
|
async def get_course(course_id: str):
|
|
"""Get full course content including all lesson slides."""
|
|
course = next((c for c in COURSES if c["id"] == course_id), None)
|
|
if not course:
|
|
raise HTTPException(status_code=404, detail="Course not found")
|
|
return course
|
|
|
|
|
|
@router.get("/scam-school/courses/{course_id}/modules/{module_id}")
|
|
async def get_module(course_id: str, module_id: str):
|
|
"""Get a specific module's content."""
|
|
course = next((c for c in COURSES if c["id"] == course_id), None)
|
|
if not course:
|
|
raise HTTPException(status_code=404, detail="Course not found")
|
|
module = next((m for m in course["modules"] if m["id"] == module_id), None)
|
|
if not module:
|
|
raise HTTPException(status_code=404, detail="Module not found")
|
|
return module
|
|
|
|
|
|
@router.get("/scam-school/progress")
|
|
async def get_progress(request: Request):
|
|
"""Get current user's full progress."""
|
|
user = await get_current_user(request)
|
|
if not user:
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
|
|
progress = _get_progress(user["id"])
|
|
level_data = _calculate_level(progress.total_xp)
|
|
|
|
return {
|
|
"user_id": progress.user_id,
|
|
"xp": progress.total_xp,
|
|
"level": level_data,
|
|
"streak": {
|
|
"current": progress.streak_days,
|
|
"longest": progress.longest_streak,
|
|
},
|
|
"badges": [BADGES[b] for b in progress.badges if b in BADGES],
|
|
"achievements": [a for a in ACHIEVEMENTS if a["id"] in progress.achievements],
|
|
"certificates": progress.certificates,
|
|
"courses_progress": progress.courses_progress,
|
|
"time_spent_total_minutes": progress.time_spent_total_minutes,
|
|
}
|
|
|
|
|
|
@router.post("/scam-school/lessons/{course_id}/{lesson_id}/complete")
|
|
async def complete_lesson(course_id: str, lesson_id: str, req: LessonComplete, request: Request):
|
|
"""Mark a lesson as complete and award XP."""
|
|
user = await get_current_user(request)
|
|
if not user:
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
|
|
course = next((c for c in COURSES if c["id"] == course_id), None)
|
|
if not course:
|
|
raise HTTPException(status_code=404, detail="Course not found")
|
|
module = next((m for m in course["modules"] if m["id"] == lesson_id), None)
|
|
if not module:
|
|
raise HTTPException(status_code=404, detail="Lesson not found")
|
|
|
|
progress = _get_progress(user["id"])
|
|
_update_streak(progress)
|
|
|
|
# Calculate score from quiz answers if provided
|
|
score = req.score or 0
|
|
if req.answers and "content" in module and "slides" in module["content"]:
|
|
quiz_slides = [s for s in module["content"]["slides"] if s.get("type") == "quiz"]
|
|
correct = 0
|
|
for ans in req.answers:
|
|
if ans.question_index < len(quiz_slides):
|
|
quiz = quiz_slides[ans.question_index]
|
|
if quiz.get("correct") == ans.answer:
|
|
correct += 1
|
|
if quiz_slides:
|
|
score = int((correct / len(quiz_slides)) * 100)
|
|
|
|
# Award XP based on score
|
|
base_xp = module.get("xp", 50)
|
|
xp_earned = int(base_xp * (score / 100)) if score > 0 else base_xp
|
|
|
|
# Update lesson progress
|
|
lesson_key = f"{course_id}:{lesson_id}"
|
|
progress.lessons_progress[lesson_key] = LessonProgress(
|
|
lesson_id=lesson_id,
|
|
course_id=course_id,
|
|
completed=True,
|
|
score=score,
|
|
xp_earned=xp_earned,
|
|
time_spent_seconds=req.time_spent_seconds,
|
|
completed_at=datetime.utcnow().isoformat(),
|
|
attempts=1,
|
|
)
|
|
|
|
# Update course progress
|
|
if course_id not in progress.courses_progress:
|
|
progress.courses_progress[course_id] = CourseProgress(
|
|
course_id=course_id,
|
|
total_lessons=len(course["modules"]),
|
|
started_at=datetime.utcnow().isoformat(),
|
|
)
|
|
|
|
course_prog = progress.courses_progress[course_id]
|
|
if lesson_id not in course_prog.completed_lessons:
|
|
course_prog.completed_lessons.append(lesson_id)
|
|
|
|
# Check if course complete
|
|
if len(course_prog.completed_lessons) >= course_prog.total_lessons:
|
|
course_prog.completed_at = datetime.utcnow().isoformat()
|
|
# Award course badge
|
|
badge = _award_badge(progress, course.get("badge_reward", ""))
|
|
# Award course XP bonus
|
|
bonus_xp = course.get("xp_reward", 0)
|
|
xp_result = _award_xp(progress, bonus_xp, f"Course complete: {course['title']}")
|
|
else:
|
|
xp_result = _award_xp(progress, xp_earned, f"Lesson complete: {module['title']}")
|
|
badge = None
|
|
|
|
progress.time_spent_total_minutes += req.time_spent_seconds // 60
|
|
|
|
# Check achievements
|
|
new_achievements = _check_achievements(progress)
|
|
for ach in new_achievements:
|
|
_award_xp(progress, ach["xp"], f"Achievement: {ach['name']}")
|
|
|
|
_save_progress(progress)
|
|
|
|
return {
|
|
"status": "ok",
|
|
"xp_earned": xp_earned,
|
|
"xp_result": xp_result,
|
|
"score": score,
|
|
"badge_awarded": badge,
|
|
"achievements": new_achievements,
|
|
"level": _calculate_level(progress.total_xp),
|
|
"streak": progress.streak_days,
|
|
}
|
|
|
|
|
|
@router.post("/scam-school/exams/{course_id}/{exam_id}")
|
|
async def submit_exam(course_id: str, exam_id: str, req: ExamSubmit, request: Request):
|
|
"""Submit an exam and get results."""
|
|
user = await get_current_user(request)
|
|
if not user:
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
|
|
course = next((c for c in COURSES if c["id"] == course_id), None)
|
|
if not course:
|
|
raise HTTPException(status_code=404, detail="Course not found")
|
|
exam = next((m for m in course["modules"] if m["id"] == exam_id and m["type"] == "exam"), None)
|
|
if not exam:
|
|
raise HTTPException(status_code=404, detail="Exam not found")
|
|
|
|
progress = _get_progress(user["id"])
|
|
|
|
# Grade exam
|
|
slides = exam.get("content", {}).get("slides", [])
|
|
quiz_slides = [s for s in slides if s.get("type") == "quiz"]
|
|
correct = 0
|
|
results = []
|
|
|
|
for i, ans in enumerate(req.answers):
|
|
if i < len(quiz_slides):
|
|
quiz = quiz_slides[i]
|
|
is_correct = quiz.get("correct") == ans.answer
|
|
if is_correct:
|
|
correct += 1
|
|
results.append(
|
|
{
|
|
"question_index": i,
|
|
"correct": is_correct,
|
|
"correct_answer": quiz.get("correct"),
|
|
"your_answer": ans.answer,
|
|
"explanation": quiz.get("explanation", ""),
|
|
}
|
|
)
|
|
|
|
total = len(quiz_slides)
|
|
score = int((correct / total) * 100) if total > 0 else 0
|
|
passing = exam.get("passing_score", 80)
|
|
passed = score >= passing
|
|
|
|
# Update exam progress
|
|
exam_key = f"{course_id}:{exam_id}"
|
|
progress.lessons_progress[exam_key] = LessonProgress(
|
|
lesson_id=exam_id,
|
|
course_id=course_id,
|
|
completed=passed,
|
|
score=score,
|
|
xp_earned=0,
|
|
time_spent_seconds=req.time_spent_seconds,
|
|
completed_at=datetime.utcnow().isoformat() if passed else None,
|
|
attempts=1,
|
|
)
|
|
|
|
if course_id not in progress.courses_progress:
|
|
progress.courses_progress[course_id] = CourseProgress(
|
|
course_id=course_id,
|
|
total_lessons=len(course["modules"]),
|
|
started_at=datetime.utcnow().isoformat(),
|
|
)
|
|
|
|
course_prog = progress.courses_progress[course_id]
|
|
course_prog.exam_score = score
|
|
course_prog.exam_passed = passed
|
|
|
|
xp_earned = 0
|
|
badge = None
|
|
certificate = None
|
|
new_achievements = []
|
|
|
|
if passed:
|
|
# Award exam XP
|
|
base_xp = exam.get("xp", 100)
|
|
xp_earned = base_xp
|
|
if score == 100:
|
|
xp_earned = int(base_xp * 1.5) # Bonus for perfect score
|
|
badge = _award_badge(progress, "perfect-score")
|
|
new_achievements.append({"id": "perfect-exam", "name": "Flawless", "xp": 500})
|
|
|
|
xp_result = _award_xp(progress, xp_earned, f"Exam passed: {exam['title']}")
|
|
|
|
# Award course badge on exam pass
|
|
if not course_prog.completed_at:
|
|
course_prog.completed_at = datetime.utcnow().isoformat()
|
|
badge_course = _award_badge(progress, course.get("badge_reward", ""))
|
|
if badge_course:
|
|
badge = badge_course
|
|
# Generate certificate
|
|
certificate = _generate_certificate(user["id"], course_id, score)
|
|
progress.certificates.append(certificate)
|
|
else:
|
|
xp_result = None
|
|
|
|
# Check achievements
|
|
ach = _check_achievements(progress)
|
|
for a in ach:
|
|
_award_xp(progress, a["xp"], f"Achievement: {a['name']}")
|
|
new_achievements.extend(ach)
|
|
|
|
_save_progress(progress)
|
|
|
|
return {
|
|
"passed": passed,
|
|
"score": score,
|
|
"passing_score": passing,
|
|
"correct": correct,
|
|
"total": total,
|
|
"results": results,
|
|
"xp_earned": xp_earned,
|
|
"xp_result": xp_result,
|
|
"badge_awarded": badge,
|
|
"certificate": certificate,
|
|
"achievements": new_achievements,
|
|
"level": _calculate_level(progress.total_xp),
|
|
}
|
|
|
|
|
|
@router.get("/scam-school/leaderboard")
|
|
async def get_leaderboard(limit: int = Query(50, ge=1, le=100)):
|
|
"""Get global leaderboard by XP."""
|
|
r = get_redis()
|
|
all_progress = r.hgetall("rmi:scam_school:progress")
|
|
|
|
entries = []
|
|
for user_id, data in all_progress.items():
|
|
try:
|
|
d = json.loads(data)
|
|
entries.append(
|
|
{
|
|
"user_id": user_id,
|
|
"xp": d.get("total_xp", 0),
|
|
"level": d.get("level", 1),
|
|
"level_title": d.get("level_title", "Novice"),
|
|
"badges_count": len(d.get("badges", [])),
|
|
"courses_completed": sum(
|
|
1 for c in d.get("courses_progress", {}).values() if c.get("completed_at")
|
|
),
|
|
"streak": d.get("streak_days", 0),
|
|
}
|
|
)
|
|
except Exception:
|
|
continue
|
|
|
|
entries.sort(key=lambda x: x["xp"], reverse=True)
|
|
|
|
# Add rank
|
|
for i, e in enumerate(entries[:limit]):
|
|
e["rank"] = i + 1
|
|
|
|
return {"leaderboard": entries[:limit], "total_learners": len(entries)}
|
|
|
|
|
|
@router.get("/scam-school/badges")
|
|
async def list_badges():
|
|
"""List all available badges."""
|
|
return {"badges": BADGES}
|
|
|
|
|
|
@router.get("/scam-school/achievements")
|
|
async def list_achievements():
|
|
"""List all achievements."""
|
|
return {"achievements": ACHIEVEMENTS}
|
|
|
|
|
|
@router.get("/scam-school/levels")
|
|
async def list_levels():
|
|
"""List level progression."""
|
|
return {"levels": LEVELS}
|
|
|
|
|
|
@router.get("/scam-school/verify-cert/{cert_id}")
|
|
async def verify_certificate(cert_id: str):
|
|
"""Verify a certificate's authenticity."""
|
|
r = get_redis()
|
|
all_progress = r.hgetall("rmi:scam_school:progress")
|
|
|
|
for user_id, data in all_progress.items():
|
|
try:
|
|
d = json.loads(data)
|
|
for cert in d.get("certificates", []):
|
|
if cert["id"] == cert_id:
|
|
return {
|
|
"valid": True,
|
|
"certificate": cert,
|
|
"user_id": user_id,
|
|
"verified_at": datetime.utcnow().isoformat(),
|
|
}
|
|
except Exception:
|
|
continue
|
|
|
|
return {"valid": False, "error": "Certificate not found"}
|