Learn.tg is a live, production gamified educational platform making learning engaging and rewarding. Students complete quality content through interactive guides and crossword puzzles, earning cryptocurrency rewards for correct answers. The platform is currently operational at https://learn.tg with multiple courses across different subjects.
The platform currently features crossword puzzles as the primary interactive assessment method. Each guide concludes with a crossword puzzle that tests the material learned. Future development plans include expanding to additional game types while maintaining the core educational value.
Course guides are Markdown files in resources/{lang}/{prefijoRuta}/guide*.md.
They contain comprehension questions as numbered lists ending with (answer):
1. The Celo native cryptocurrency is called ___ . (CELO)The parser (lib/remarkFillInTheBlank.mjs) extracts questions and answers
using regex /^(.*___.*)\s+\(([^)]+)\)\s*$/s. Key behaviors:
- Questions with
___become crossword clues (max 5 shown, randomly selected from all available). - The answer in
(...)is captured —[^)]+allows parentheses in the question body (e.g. formulas) without interference. - Answers are stored in
billetera_usuario.answer_fibas pipe-separated values (|) and validated against user submissions. - Guide order is controlled by
nombrecortoincor1440_gen_actividadpf(text sort). ThesufijoRutacolumn must match the filename.
See Guide Writing Conventions for the full style guide.
When a student completes 100% of guides in a course, an SBT (Soul-Bound Token)
is minted via lib/credentials.ts → mintCourseCredential(). The SBT is
non-transferable proof of learning, displayed on the user's public profile.
Emission records are cached in credential_emission and metadata in
credential_metadata.
This diagram illustrates the flow of information and actions between the user, the different parts of the application, and the blockchain.
graph TD
subgraph User
A[User's Wallet]
B[Browser]
end
subgraph Platform
C[Frontend - Next.js/React]
D[Backend - Rails]
E[Smart Contract - Solidity/Celo]
F[Backend - Next.js API]
end
subgraph Blockchain
G[Celo Network]
end
A -- 1. Connects & Signs (SIWE) --> C
B -- 2. Interacts with Content --> C
C -- 3. Fetches Guides/Data (JWT Auth) --> D
D -- 4. Returns Content --> C
C -- 5. Submits Answers/Actions --> F
F -- 6. Validates, Records Events, Triggers Reward --> E
E -- 7. Executes Transaction --> G
G -- 8. Sends USDT, SLEARN, and CELO Rewards --> A
style E fill:#f9f,stroke:#333,stroke-width:2px
style D fill:#bbf,stroke:#333,stroke-width:2px
style C fill:#9f9,stroke:#333,stroke-width:2px
style F fill:#9f9,stroke:#333,stroke-width:2px
| Service | Port | Purpose |
|---|---|---|
| Next.js dev server | 4000 |
UI + API routes (local development) |
| Rails API | 3500 |
Admin backend (/learntg-admin) |
| Next.js API (HTTPS) | 9001 |
API proxied to live server for quickstart mode |
- Framework: Ruby on Rails (v8.0)
- Database: PostgreSQL (>= 16.2) with unaccent extension
- Purpose: Course management, guide organization, user data persistence, teacher administration.
- Based on: MSIP and cor1440_gen frameworks
- Authentication: Receives and validates tokens from the Next.js frontend (the same CSRF token used for SIWE, stored in
billetera_usuario.token).
- Framework: Next.js with React + TypeScript.
- UI Components: Utilizes Radix UI for building a flexible and accessible component library. Shared utilities and CLI tooling from
@pasosdejesus/m(i18n, WBA metrics, E2E test harness, debug console, Kysely mocks, shadcn components, blockchain helpers). - Purpose: User interface, content delivery, and user authentication.
- Authentication: Implements Sign-In With Ethereum (SIWE) with a custom
window.ethereum-based wallet layer (no RainbowKit, no wagmi). See SIWE Auth Flow and Wallet Auth for details. - Note: For a detailed technical breakdown of the Next.js application, refer to the
README.mdfile within theapps/nextjsdirectory.
- Language: Solidity (^0.8.24)
- Network: Celo (mainnet) & Celo Sepolia (testnet)
- Contracts:
LearnTGVaultsV4.sol: Manages USDT and SLEARN scholarship rewards for crossword puzzle completions (active).CeloUBI.sol: Manages periodic claims of Universal Basic Income (UBI) in CELO.SLEARN.sol: ERC-20 utility token, 2 decimals, restricted transfers.PasosDeJesusCredentials.sol: Course completion SBTs (Soul-Bound Tokens).
- Frontend (Next.js): A user connects their wallet and signs a message (SIWE).
- Next.js API: Validates the signature and generates a JWT token. This token is stored and used for subsequent authenticated actions.
- Backend Communication:
- For fetching course data and content, the frontend sends the
JWT in the
Authorizationheader to the Rails backend. - For submitting answers and triggering rewards, the frontend sends
the answers along with the JWT to a specific Next.js API route
(
/api/check-crossword).
- For fetching course data and content, the frontend sends the
JWT in the
Key Point: The platform uses a hybrid backend approach. The Rails server acts as the primary administrative and content backend, while the Next.js server handles real-time, user-specific actions like answer validation and blockchain interactions.
The platform features two distinct reward mechanisms, demonstrating our principle of amor through tangible provision.
- Trigger: A student submits a crossword answer via the
/api/check-crosswordendpoint. - Process:
- The Next.js backend validates the answer.
- If correct, it calls the
payScholarship()function on theLearnTGVaultsV4.solcontract. - The contract verifies on-chain that the user has a
profileScoreof at least 50, has not already been rewarded for the guide, and has respected the 24-hour cooldown period. TheprofileScorebreakdown and scholarship formula are documented in the user-facing course: Web3 & UBI — Guide 2b. - If checks pass, the contract calculates and transfers USDT and SLEARN rewards to the student's wallet.
- When 100% of guides in a course are completed,
mintCourseCredential()issues an SBT viaPasosDeJesusCredentials.sol.
- Trigger: A user initiates a UBI claim via the
/api/claim-celo-ubiendpoint. - Process:
- The Next.js backend verifies the user's eligibility (e.g., wallet,
profileScore, potential cooldowns). - It then calls the
claim()function on theCeloUBI.solcontract. - The contract validates the claim conditions (such as cooldown periods) on-chain.
- Upon successful validation, it transfers a set amount of CELO to the user's wallet.
- The Next.js backend verifies the user's eligibility (e.g., wallet,
The platform tracks user progress and scores through two main database tables:
points: 1 if the guide was answered correctly, 0 otherwiseamountpaid: Amount rewarded for this guide — USDT if paid, otherwise SLEARN (0 if not yet paid)profilescore: User's profile score at the time of submission
learningscore: Total points across all guides and coursesprofilescore: Metric of user engagement (≥50 required for rewards)
When a user answers a guide correctly:
guide_usuario.pointsis set to 1updateUserAndCoursePoints()recalculates the user's totallearningscore- Progress percentages are recomputed based on completed vs total guides
The frontend displays progress using a three-color arc showing completion and payment percentages.
Verifiers access /en/admin to manage users and churches. Key features:
- Pending Verifications: Users who requested interviews
- Recent Users / Churches: Quick access to recently modified records
- User Edit Modal: Edit profile fields, verify data (checkboxes), assign churches, schedule interviews
- Church Edit Modal: Edit church details, view members, delete (soft-delete)
- Calendar: Verifier availability managed via CalDAV (Radicale)
See Admin API for endpoint details.
- Managed by verifiers via
/api/admin/churches - Linked to users via
usuario.church_id - Tracks pastor info, location, denomination, registration
credential_emission: Records SBT minting events (user, course, tokenId, txHash)credential_metadata: Cached on-chain metadata (name, type, image URL, premium status)
In line with our principle of transparency, the project uses a robust, server-side-only metrics system to ensure data integrity and prevent client-side event manipulation.
- Core Principle: The backend is the single source of truth. Events are not tracked by the client; they are recorded by the server as a side-effect of API calls being successfully processed. For example, a
guide_viewevent is recorded when the/api/guideendpoint serves the guide content. - Implementation: The logic is handled within the Next.js backend. For detailed implementation, data flow, and the philosophy behind this approach, please refer to the
README.mdfile in theapps/nextjsdirectory.
The existing Metrics Dashboard (/metrics) visualizes this reliable, server-recorded data.
The PostgreSQL database is shared between the Rails backend and Next.js backend. Rails manages the core schema through ActiveRecord migrations, while Next.js accesses the same tables directly via the Kysely ORM for performance-critical operations like answer validation and progress tracking.
id: Primary key, referenced ascourseIdthroughout the platformtitulo: Course title displayed to userssubtitulo: Optional subtitleprefijoRuta: URL path prefix (e.g., "gooddollar")idioma: Language ("en" or "es")- Additional administrative fields for project management
id: Primary key, referenced asactividadpf_idin related tablesproyectofinanciero_id: Foreign key tocor1440_gen_proyectofinancierotitulo: Guide titlenombrecorto: Short name used for orderingsufijoRuta: URL path suffix (e.g., "guide1") - must be non-empty for published guidesdescripcion: Optional description
- Guides are filtered by
proyectofinanciero_id = {courseId}andsufijoRuta IS NOT NULL AND sufijoRuta <> '' - In the crossword validation API (
/api/check-crossword), guides are ordered bynombrecorto - Guide numbers (1-indexed) correspond to the position in this ordered list
- The frontend uses
sufijoRutato construct URLs:/{lang}/{prefijoRuta}/{sufijoRuta} - Note: While the validation API orders by
nombrecorto, other parts of the system (like the Rails backend API) may use different ordering. TheguideIdparameter in API requests is 1-indexed (position in ordered list), but the smart contract receivesactividadpf_id(database ID fromcor1440_gen_actividadpf.id). The backend translates between them viaguides.rows[guideId - 1].id.
id: Primary keyprofilescore: Engagement metric (≥50 required for blockchain rewards)learningscore: Total points across all guides and coursesupdated_at: Last update timestamp
billetera: Wallet address (case-insensitive)usuario_id: Foreign key tousuariotoken: JWT token for API authenticationanswer_fib: Stores correct crossword answers (pipe-separated|) provided by the Rails backend after guide viewing
usuario_id,actividadpf_id: Composite primary keypoints: 1 if the guide was answered correctly, 0 otherwiseamountpaid: USDT amount rewarded for this guide (0 if not paid)profilescore: Snapshot of user'sprofilescoreat submission time
id: Primary keyusuario_id: Optional foreign key tousuarioevent_type: Type identifier (guide_view, game_start, etc.)event_data: JSON payload with event-specific datacreated_at: Timestamp of event
Single source of truth for all value movements — both on-chain (USDT, SLEARN, CELO) and off-chain (Learning Points). Every operation that changes a user's balance or records a blockchain interaction creates an entry here.
id: Primary keyusuario_id: Foreign key tousuariowallet: Wallet address that signed the transactiontype: Operation type —scholarship(crossword reward),donation(user gave value),donation_reward(SLEARN cashback for donating),pay-course(premium course payment),ubi-claim(CELO basic income),conversion(SLEARN ↔ Learning Points)crypto: Asset —usdt,slearn,celoamount: Human-readable amount (e.g., 10.00 USDT, 5.50 SLEARN)balance_impact: Net effect on user's balance — negative for outflows (donations), positive for inflows (rewards, scholarships)hash: Blockchain transaction hash (null for off-chain operations like Learning Points)categoria: Logical grouping (e.g.,'donation'groups all donation types)subcategoria: Detail within category —'course'(course donation),'cluster'(cluster donation),'country'(country donation)metadata: JSON payload with operation-specific data (courseId, clusterId, processPaymentHash, etc.)date,created_at,updated_at: Timestampsdescripcion: Optional human descriptionsynced: Whether synced with blockchain state
Constraints:
typeCHECK:scholarship | donation | donation_reward | pay-course | ubi-claim | conversionhashUNIQUE (prevents replay attacks)
Usage in leaderboard: Leaderboard metrics aggregate this table — SLEARN net balance via SUM(balance_impact), scholarships via SUM(amount) WHERE type='scholarship', donations via SUM(amount) WHERE type='donation'. The leaderboard does not filter by subcategoria, showing total user donation activity regardless of destination.
- User wallet connection creates/updates
billetera_usuariowith a signed token - Course and guide data is fetched from Rails APIs (which query the above tables)
- Crossword answers are validated against
billetera_usuario.answer_fib - Progress is tracked in
guide_usuario - Blockchain rewards update
guide_usuario.amountpaid - User events (guide views, game completions, etc.) are recorded in
usereventvia the metrics tracking system - All blockchain transactions and value movements are recorded in
transaction— single source of truth for leaderboard, transparency dashboard, and user transaction history
Policy: Derived/cached data (scores, aggregates, rankings) must be kept fresh via database triggers, not periodic cron jobs or application-level timers. A cache row is stale only between the start and commit of the triggering transaction — never longer.
- Triggers fire on data change, not on a clock. Every INSERT/UPDATE/DELETE on source tables recalculates affected caches immediately.
- Triggers are PostgreSQL functions defined in migrations alongside the tables they observe.
- Application code never writes to cache tables directly — only triggers do.
- Cache tables are read-only from the application layer.
-- Trigger on transaction table: recalculate cluster score when SLEARN/USDT changes
CREATE OR REPLACE FUNCTION update_cluster_score_from_transaction()
RETURNS TRIGGER AS $$
BEGIN
-- Recalculate affected cluster score cache
PERFORM refresh_cluster_score(NEW.usuario_id);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_transaction_cluster_score
AFTER INSERT OR UPDATE ON transaction
FOR EACH ROW
EXECUTE FUNCTION update_cluster_score_from_transaction();| Scenario | Use |
|---|---|
| Aggregate score/rank from multiple tables | Trigger |
Single-row derived value (e.g., balance = SUM(...)) |
Trigger |
| Complex multi-step business logic with external APIs | Application code (call trigger-compatible functions) |
| Real-time notifications (WebSocket, push) | Application code after DB commit |
If multiple triggers fire on the same event, they execute in alphabetical order by trigger name. Prefix triggers with a sequence number when order matters: trg_01_..., trg_02_....
Communications between users, and between pdJ and users, are confidential by default.
- Communications are not stored in the database unless explicitly required by a REQ.
- If stored, they must be encrypted using the pattern defined in R-#153 (AES-256-GCM with wallet-derived DEK for users, ECIES for pdJ access).
- API responses never include message content — only metadata (timestamps, status, participant IDs).
- Private notes and internal communications use the messaging system (R-#162), not free-text columns on entity tables.
- User-facing text fields (cluster names, course descriptions, public profiles) may be plaintext — they are intentionally public.
| Data | Handling |
|---|---|
| Pastor names, WhatsApp, ID documents | Encrypted (R-#153) |
| Church locations in persecution contexts | Encrypted or stored as country-only |
| GD contact notes, internal pdJ notes | R-#162 messaging (Phase 2), not in entity tables |
| Private messages between users | R-#162 encrypted messages |
| Donor identities (if anonymous) | Wallet address only, no personal data |
// ⏳ Planned (REQ/157) — not yet implemented
GET /api/gd/contact/:clusterId
→ { cluster_sent_at, pdj_sent_at, gd_responded_at, released_at, status }
// ❌ Never — message content or notes
// Use R-#162 messaging endpoints instead| Layer | Algorithm | Quantum resistance |
|---|---|---|
| Data at rest | AES-256-GCM | ✅ Grover's reduces to ~128-bit effective — still infeasible |
| User DEK derivation | ECDSA (secp256k1) via personal_sign |
❌ Shor's can recover private key → reconstruct DEK |
| pdJ DEK encryption | ECIES over secp256k1 | ❌ Shor's breaks ECC → DEK exposed |
Impact: If a cryptographically relevant quantum computer emerges (~4000+ logical qubits), all secp256k1-based operations are compromised. This is not specific to learn.tg — it would break all Ethereum wallets, all smart contracts, and the entire Celo network simultaneously. The mitigation path is two-fold: (1) Ethereum's migration to post-quantum signatures for on-chain operations, and (2) R-#166 introduces SPHINCS+ (NIST-standardized, hash-based) at the off-chain encryption layer for learn.tg, which is independent of Ethereum's timeline. A CRQC is widely estimated at 10-20+ years out by NIST, NSA, and academic consensus.
| Component | Tech | Purpose |
|---|---|---|
| Major Backend | Rails + PostgreSQL | Course/user management, admin interface |
| Smart Contracts | Solidity + Hardhat | USDT reward distribution, vault management |
| Frontend & API | Next.js + React + TypeScript | UI, content delivery, SIWE auth, answer validation |
| Blockchain | Celo | USDT transfers, decentralized rewards |
| Content | Markdown | Guide storage, version control |
| Metrics & Analytics | Next.js + Recharts + PostgreSQL | User engagement tracking, performance metrics, dashboard visualization |
| Wallet | OKX/Metamask/etc | User authentication and reward receipt |
Contract addresses are stored as JSON files in apps/hardhat/deployments/{Contract}/{network}.json,
not in .env variables. This is the single source of truth for all environments.
apps/hardhat/deployments/
SLEARN/{network}.json
LearnTGVaults/V4/{network}.json
ClusterFunds/{network}.json
MockUSDT/{network}.json
CeloUbi/{network}.json
Reading addresses in scripts (apps/hardhat/scripts/):
import * as path from "path"
import * as fs from "fs"
const network = process.env.NEXT_PUBLIC_NETWORK || "celoSepolia"
const file = path.join(__dirname, "..", "deployments", "ClusterFunds", `${network}.json`)
const { address } = JSON.parse(fs.readFileSync(file, "utf8"))Reading addresses in Next.js backend (apps/nextjs/):
Use @pasosdejesus/m/blockchain/deployments:
import { readDeployment } from "@pasosdejesus/m/blockchain/deployments"
const network = process.env.NEXT_PUBLIC_NETWORK || "celoSepolia"
const deployment = readDeployment(network,
path.join(process.cwd(), "..", "hardhat", "deployments"),
{ contract: "ClusterFunds" }
)
const address = deployment?.addressDo NOT add NEXT_PUBLIC_*_ADDRESS environment variables for contract addresses.
Environment variables are for secrets and network configuration (RPC URLs, API keys),
not for derived data like deployment addresses.
- Start order: Rails backend first, then Next.js frontend
- Environment: adJ 7.8 recommended (includes all dependencies)
- Testing: Hound CI, CodeClimate integration
- Code Quality: Automated linting and security checks
- Live Deployment: Running at https://learn.tg
All endpoints under /api/admin/* that expose or modify sensitive data MUST
require authentication. The admin dashboard's client-side wallet check is NOT
sufficient — API endpoints must independently verify the caller.
- Every admin endpoint must read
NEXT_PUBLIC_VERIFIER_WALLETand reject requests that don't include a valid verifier wallet address. - The wallet address must be sent as a query parameter (
?wallet=0x...) or in the request body. - Calendar endpoints (
/api/admin/calendar/*) are equally sensitive — they expose verifier schedules and must be protected. - Read-only endpoints (
GET /api/admin/users, etc.) are also sensitive (user emails, phone numbers, verification status) and require auth.
const VERIFIER_WALLETS = (process.env.NEXT_PUBLIC_VERIFIER_WALLET || '')
.split(',').map(w => w.trim().toLowerCase()).filter(Boolean)
function isVerifier(wallet: string): boolean {
return VERIFIER_WALLETS.length > 0 && VERIFIER_WALLETS.includes(wallet.toLowerCase())
}GET /api/admin/check-verifieris intentionally public — it lets the frontend verify if a wallet is a verifier without exposing data.