From d8bc8e33923e399b08ba374af2024963ef97627e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CPeteroche=E2=80=9D?= <“petergoddey08l@gmail.com”> Date: Fri, 27 Mar 2026 07:12:56 +0100 Subject: [PATCH 1/3] refactor: Explicitly name the Progress entity's table as user_progress and remove the UserProgress entity. --- .../src/progress/entities/progress.entity.ts | 2 +- .../progress/entities/user-progress.entity.ts | 38 ------------------- 2 files changed, 1 insertion(+), 39 deletions(-) delete mode 100644 backend/src/progress/entities/user-progress.entity.ts diff --git a/backend/src/progress/entities/progress.entity.ts b/backend/src/progress/entities/progress.entity.ts index 4b7d648..ed3806d 100644 --- a/backend/src/progress/entities/progress.entity.ts +++ b/backend/src/progress/entities/progress.entity.ts @@ -11,7 +11,7 @@ import { Puzzle } from '../../puzzles/entities/puzzle.entity'; import { Category } from '../../categories/entities/category.entity'; import { DailyQuest } from '../../quests/entities/daily-quest.entity'; -@Entity() +@Entity('user_progress') @Index(['userId', 'attemptedAt']) @Index(['userId', 'puzzleId']) @Index(['categoryId']) diff --git a/backend/src/progress/entities/user-progress.entity.ts b/backend/src/progress/entities/user-progress.entity.ts deleted file mode 100644 index a4de844..0000000 --- a/backend/src/progress/entities/user-progress.entity.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { - Entity, - Column, - PrimaryGeneratedColumn, - CreateDateColumn, - Index, -} from 'typeorm'; - -@Entity('user_progress') -@Index(['userId', 'categoryId', 'attemptedAt']) -export class UserProgress { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ type: 'uuid', nullable: false }) - userId: string; - - @Column({ type: 'uuid', nullable: false }) - puzzleId: string; - - @Column({ type: 'uuid', nullable: false }) - categoryId: string; - - @Column({ type: 'boolean', nullable: false }) - isCorrect: boolean; - - @Column({ type: 'text', nullable: false }) - userAnswer: string; - - @Column({ type: 'integer', nullable: false }) - pointsEarned: number; - - @Column({ name: 'time_spent', type: 'integer', nullable: false }) - timeSpent: number; // seconds - - @CreateDateColumn({ name: 'attempted_at' }) - attemptedAt: Date; -} From f2dc4e68a075d7839b0a703f9ab8e41563f54ebb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CPeteroche=E2=80=9D?= <“petergoddey08l@gmail.com”> Date: Fri, 27 Mar 2026 07:54:09 +0100 Subject: [PATCH 2/3] feat: Implement GetPlayerProvider to fetch on-chain player data, updating the Soroban contract and adding new test snapshots. --- backend/src/blockchain/blockchain.module.ts | 3 +- .../blockchain/provider/blockchain.service.ts | 12 ++ .../providers/get-player.provider.spec.ts | 151 ++++++++++++++++++ .../providers/get-player.provider.ts | 88 ++++++++++ contract/src/lib.rs | 115 ++++++++++++- 5 files changed, 361 insertions(+), 8 deletions(-) create mode 100644 backend/src/blockchain/providers/get-player.provider.spec.ts create mode 100644 backend/src/blockchain/providers/get-player.provider.ts diff --git a/backend/src/blockchain/blockchain.module.ts b/backend/src/blockchain/blockchain.module.ts index a10e592..7b5f333 100644 --- a/backend/src/blockchain/blockchain.module.ts +++ b/backend/src/blockchain/blockchain.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { BlockchainController } from './controller/blockchain.controller'; import { BlockchainService } from './provider/blockchain.service'; +import { GetPlayerProvider } from './providers/get-player.provider'; @Module({ controllers: [BlockchainController], - providers: [BlockchainService], + providers: [BlockchainService, GetPlayerProvider], exports: [BlockchainService], }) export class BlockchainModule {} diff --git a/backend/src/blockchain/provider/blockchain.service.ts b/backend/src/blockchain/provider/blockchain.service.ts index ed4dbbb..ac889bb 100644 --- a/backend/src/blockchain/provider/blockchain.service.ts +++ b/backend/src/blockchain/provider/blockchain.service.ts @@ -1,8 +1,20 @@ import { Injectable } from '@nestjs/common'; +import { GetPlayerProvider } from '../providers/get-player.provider'; @Injectable() export class BlockchainService { + constructor(private readonly getPlayerProvider: GetPlayerProvider) {} + getHello(): string { return 'Hello from Blockchain Service'; } + + /** + * Fetches a player's on-chain profile from the Soroban contract. + * @param stellarWallet The player's Stellar wallet address. + * @returns The player object if found, null otherwise. + */ + async getPlayerOnChain(stellarWallet: string): Promise { + return this.getPlayerProvider.getPlayerOnChain(stellarWallet); + } } diff --git a/backend/src/blockchain/providers/get-player.provider.spec.ts b/backend/src/blockchain/providers/get-player.provider.spec.ts new file mode 100644 index 0000000..e0fdeac --- /dev/null +++ b/backend/src/blockchain/providers/get-player.provider.spec.ts @@ -0,0 +1,151 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { GetPlayerProvider } from './get-player.provider'; +import * as StellarSdk from 'stellar-sdk'; + +// Mock StellarSdk +jest.mock('stellar-sdk', () => { + return { + rpc: { + Server: jest.fn().mockImplementation(() => ({ + simulateTransaction: jest.fn(), + })), + Api: { + isSimulationSuccess: jest.fn() as unknown as jest.Mock, + }, + }, + Contract: jest.fn().mockImplementation(() => ({ + call: jest.fn().mockReturnValue({}), + })), + Address: { + fromString: jest.fn().mockReturnValue({}), + }, + Account: jest.fn(), + TransactionBuilder: jest.fn().mockImplementation(() => ({ + addOperation: jest.fn().mockReturnThis(), + setTimeout: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({}), + })), + Networks: { + TESTNET: 'testnet', + }, + TimeoutInfinite: 0, + nativeToScVal: jest.fn(), + scValToNative: jest.fn(), + xdr: { + ScValType: { + scvVoid: jest.fn().mockImplementation(() => ({ + value: 0, + switch: () => 0, + })), + }, + }, + }; +}); + +describe('GetPlayerProvider', () => { + let provider: GetPlayerProvider; + let configService: ConfigService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GetPlayerProvider, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => { + if (key === 'SOROBAN_CONTRACT_ID') return 'CA1234567890'; + if (key === 'SOROBAN_RPC_URL') return 'https://soroban-testnet.stellar.org'; + return null; + }), + }, + }, + ], + }).compile(); + + provider = module.get(GetPlayerProvider); + configService = module.get(ConfigService); + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(provider).toBeDefined(); + }); + + describe('getPlayerOnChain', () => { + const mockWallet = 'GABC...'; + + it('should return player data when simulation is successful and player exists', async () => { + const mockPlayerData = { + address: mockWallet, + username: 'testuser', + xp: 100, + }; + + (StellarSdk.rpc.Api.isSimulationSuccess as unknown as jest.Mock).mockReturnValue(true); + (StellarSdk.scValToNative as jest.Mock).mockReturnValue(mockPlayerData); + + const server = (provider as any).server; + server.simulateTransaction.mockResolvedValue({ + result: { + retval: { + switch: jest.fn().mockReturnValue(1), // Not void + }, + }, + }); + + const result = await provider.getPlayerOnChain(mockWallet); + + expect(result).toEqual(mockPlayerData); + expect(server.simulateTransaction).toHaveBeenCalled(); + }); + + it('should return null when player is not found (void response)', async () => { + (StellarSdk.rpc.Api.isSimulationSuccess as unknown as jest.Mock).mockReturnValue(true); + (StellarSdk.xdr.ScValType.scvVoid as jest.Mock).mockReturnValue({ value: 0 }); + + const server = (provider as any).server; + server.simulateTransaction.mockResolvedValue({ + result: { + retval: { + switch: jest.fn().mockReturnValue({ value: 0 }), // Void + }, + }, + }); + + const result = await provider.getPlayerOnChain(mockWallet); + + expect(result).toBeNull(); + }); + + it('should return null when simulation fails', async () => { + (StellarSdk.rpc.Api.isSimulationSuccess as unknown as jest.Mock).mockReturnValue(false); + + const result = await provider.getPlayerOnChain(mockWallet); + + expect(result).toBeNull(); + }); + + it('should return null and log error when RPC call throws', async () => { + const server = (provider as any).server; + server.simulateTransaction.mockRejectedValue(new Error('Network error')); + + const result = await provider.getPlayerOnChain(mockWallet); + + expect(result).toBeNull(); + }); + + it('should return null if contractId is missing', async () => { + jest.spyOn(configService, 'get').mockReturnValue(null); + + // Need to re-instantiate or bypass constructor for this test if contractId is set once + // but the current implementation sets it in constructor. + // Let's just mock the instance property for the test. + (provider as any).contractId = null; + + const result = await provider.getPlayerOnChain(mockWallet); + expect(result).toBeNull(); + }); + }); +}); diff --git a/backend/src/blockchain/providers/get-player.provider.ts b/backend/src/blockchain/providers/get-player.provider.ts new file mode 100644 index 0000000..883f178 --- /dev/null +++ b/backend/src/blockchain/providers/get-player.provider.ts @@ -0,0 +1,88 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as StellarSdk from 'stellar-sdk'; + +@Injectable() +export class GetPlayerProvider { + private readonly logger = new Logger(GetPlayerProvider.name); + private readonly server: StellarSdk.rpc.Server; + private readonly contractId: string | undefined; + + constructor(private readonly configService: ConfigService) { + const rpcUrl = + this.configService.get('SOROBAN_RPC_URL') || + 'https://soroban-testnet.stellar.org'; + this.server = new StellarSdk.rpc.Server(rpcUrl); + this.contractId = this.configService.get('SOROBAN_CONTRACT_ID'); + } + + /** + * Fetches a player's on-chain profile from the Soroban contract. + * @param stellarWallet The player's Stellar wallet address. + * @returns The player object if found, null otherwise. + */ + async getPlayerOnChain(stellarWallet: string): Promise { + try { + if (!this.contractId) { + this.logger.error('SOROBAN_CONTRACT_ID is not defined in environment variables'); + return null; + } + + // 1. Prepare contract and address + const contract = new StellarSdk.Contract(this.contractId); + const address = StellarSdk.Address.fromString(stellarWallet); + + // 2. Build simulation transaction + // We use a dummy source account as this is a read-only simulation + const sourceAccount = new StellarSdk.Account( + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + '0', + ); + + const transaction = new StellarSdk.TransactionBuilder(sourceAccount, { + fee: '100', + networkPassphrase: StellarSdk.Networks.TESTNET, + }) + .addOperation( + contract.call( + 'get_player', + StellarSdk.nativeToScVal(address, { type: 'address' }), + ), + ) + .setTimeout(StellarSdk.TimeoutInfinite) + .build(); + + // 3. Simulate the transaction + const simulation = await this.server.simulateTransaction(transaction); + + // 4. Handle results + if (StellarSdk.rpc.Api.isSimulationSuccess(simulation)) { + const resultVal = simulation.result?.retval; + + // If result is null/void, it means the player wasn't found (Option::None) + if ( + !resultVal || + resultVal.switch().value === StellarSdk.xdr.ScValType.scvVoid().value + ) { + this.logger.debug(`Player ${stellarWallet} not found on-chain.`); + return null; + } + + // Convert XDR value to native JS object + const player = StellarSdk.scValToNative(resultVal); + this.logger.log(`Successfully fetched on-chain stats for ${stellarWallet}`); + + return player; + } + + this.logger.warn(`Simulation failed for get_player(${stellarWallet})`); + return null; + } catch (error) { + this.logger.error( + `Error fetching player on-chain (${stellarWallet}): ${error.message}`, + error.stack, + ); + return null; + } + } +} diff --git a/contract/src/lib.rs b/contract/src/lib.rs index f52b461..558e77a 100644 --- a/contract/src/lib.rs +++ b/contract/src/lib.rs @@ -1,7 +1,7 @@ #![no_std] use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; -#[derive(Clone)] +#[derive(Clone, Debug, PartialEq)] // Added Debug and PartialEq for tests #[contracttype] pub struct Player { pub address: Address, @@ -22,6 +22,12 @@ pub struct PuzzleSubmission { pub timestamp: u64, } +#[derive(Clone)] +#[contracttype] +pub enum DataKey { + PlayerIndex, +} + #[contract] pub struct MindBlockContract; @@ -41,6 +47,28 @@ impl MindBlockContract { }; env.storage().instance().set(&player, &new_player); + + // Update player index + let mut index: Vec
= env + .storage() + .persistent() + .get(&DataKey::PlayerIndex) + .unwrap_or_else(|| Vec::new(&env)); + + // Check if player is already in index + let mut exists = false; + for i in 0..index.len() { + if index.get(i).unwrap() == player { + exists = true; + break; + } + } + + if !exists { + index.push_back(player); + env.storage().persistent().set(&DataKey::PlayerIndex, &index); + } + new_player } @@ -92,12 +120,50 @@ impl MindBlockContract { } /// Get top players by XP (leaderboard) - pub fn get_leaderboard(env: Env, _limit: u32) -> Vec { - // Note: In production, implement proper pagination and sorting - // This is a simplified version - // This would need to be implemented with proper indexing - // For now, returns empty vector as placeholder - Vec::new(&env) + pub fn get_leaderboard(env: Env, limit: u32) -> Vec { + let index: Vec
= env + .storage() + .persistent() + .get(&DataKey::PlayerIndex) + .unwrap_or_else(|| Vec::new(&env)); + + let mut players = Vec::new(&env); + for i in 0..index.len() { + let addr = index.get(i).unwrap(); + if let Some(player_data) = env.storage().instance().get::(&addr) { + players.push_back(player_data); + } + } + + // Sort players by XP descending using bubble sort (Soroban Vec is immutable, so we build a new one) + // This is inefficient for large N, but works for now. + if players.is_empty() { + return players; + } + + let n = players.len(); + let mut sorted = players; + + // Bubble sort implementation on Soroban Vec + for i in 0..n { + for j in 0..n - i - 1 { + let p1 = sorted.get(j).unwrap(); + let p2 = sorted.get(j + 1).unwrap(); + if p1.xp < p2.xp { + sorted.set(j, p2); + sorted.set(j + 1, p1); + } + } + } + + // Apply limit + let mut limited = Vec::new(&env); + let count = if limit < n { limit } else { n }; + for i in 0..count { + limited.push_back(sorted.get(i).unwrap()); + } + + limited } /// Update player IQ level @@ -185,4 +251,39 @@ mod test { assert!(xp > 0); } + + #[test] + fn test_leaderboard_sorting() { + let env = Env::default(); + let contract_id = env.register(MindBlockContract, ()); + let client = MindBlockContractClient::new(&env, &contract_id); + + let p1 = Address::generate(&env); + let p2 = Address::generate(&env); + let p3 = Address::generate(&env); + + let category = String::from_str(&env, "coding"); + + env.mock_all_auths(); + + client.register_player(&p1, &String::from_str(&env, "Alice"), &10); + client.register_player(&p2, &String::from_str(&env, "Bob"), &20); + client.register_player(&p3, &String::from_str(&env, "Charlie"), &30); + + // Accumulate XP + client.submit_puzzle(&p1, &1, &category, &50); // Alice: (50 * 10) / 10 = 50 XP + client.submit_puzzle(&p2, &1, &category, &50); // Bob: (50 * 20) / 10 = 100 XP + client.submit_puzzle(&p3, &1, &category, &50); // Charlie: (50 * 30) / 10 = 150 XP + + let leaderboard = client.get_leaderboard(&5); + assert_eq!(leaderboard.len(), 3); + assert_eq!(leaderboard.get(0).unwrap().address, p3); // Charlie first + assert_eq!(leaderboard.get(1).unwrap().address, p2); // Bob second + assert_eq!(leaderboard.get(2).unwrap().address, p1); // Alice third + + // Test limit + let leaderboard_limit = client.get_leaderboard(&1); + assert_eq!(leaderboard_limit.len(), 1); + assert_eq!(leaderboard_limit.get(0).unwrap().address, p3); + } } From 7e7c7ba7c91689f90e02772a5160415b4721b731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CPeteroche=E2=80=9D?= <“petergoddey08l@gmail.com”> Date: Fri, 27 Mar 2026 07:59:53 +0100 Subject: [PATCH 3/3] test: Add snapshots for leaderboard sorting, player registration, and puzzle submission contract tests. --- contract/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/contract/src/lib.rs b/contract/src/lib.rs index 558e77a..2e5fbe7 100644 --- a/contract/src/lib.rs +++ b/contract/src/lib.rs @@ -66,7 +66,9 @@ impl MindBlockContract { if !exists { index.push_back(player); - env.storage().persistent().set(&DataKey::PlayerIndex, &index); + env.storage() + .persistent() + .set(&DataKey::PlayerIndex, &index); } new_player