diff --git a/backend/src/blockchain/blockchain.module.ts b/backend/src/blockchain/blockchain.module.ts index 4a004234..b79dd6d5 100644 --- a/backend/src/blockchain/blockchain.module.ts +++ b/backend/src/blockchain/blockchain.module.ts @@ -3,10 +3,16 @@ import { BlockchainController } from './controller/blockchain.controller'; import { BlockchainService } from './provider/blockchain.service'; import { GetPlayerProvider } from './providers/get-player.provider'; import { RegisterPlayerProvider } from './providers/register-player.provider'; +import { SyncXpMilestoneProvider } from './providers/sync-xp-milestone.provider'; @Module({ controllers: [BlockchainController], - providers: [BlockchainService, GetPlayerProvider, RegisterPlayerProvider], + providers: [ + BlockchainService, + GetPlayerProvider, + RegisterPlayerProvider, + SyncXpMilestoneProvider, + ], exports: [BlockchainService], }) export class BlockchainModule {} diff --git a/backend/src/blockchain/provider/blockchain.service.ts b/backend/src/blockchain/provider/blockchain.service.ts index b9f7370b..a1dbc9d8 100644 --- a/backend/src/blockchain/provider/blockchain.service.ts +++ b/backend/src/blockchain/provider/blockchain.service.ts @@ -1,12 +1,14 @@ import { Injectable } from '@nestjs/common'; import { GetPlayerProvider } from '../providers/get-player.provider'; import { RegisterPlayerProvider } from '../providers/register-player.provider'; +import { SyncXpMilestoneProvider } from '../providers/sync-xp-milestone.provider'; @Injectable() export class BlockchainService { constructor( private readonly getPlayerProvider: GetPlayerProvider, private readonly registerPlayerProvider: RegisterPlayerProvider, + private readonly syncXpMilestoneProvider: SyncXpMilestoneProvider, ) {} getHello(): string { @@ -40,4 +42,23 @@ export class BlockchainService { iqLevel, ); } + + /** + * Syncs an XP milestone (level-up event) to the blockchain. + * This is a fire-and-forget operation - failures are logged but never thrown. + * @param stellarWallet The player's Stellar wallet address. + * @param newLevel The player's new level after level-up. + * @param totalXp The player's total XP at time of level-up. + */ + async syncXpMilestone( + stellarWallet: string, + newLevel: number, + totalXp: number, + ): Promise { + return this.syncXpMilestoneProvider.syncXpMilestone( + stellarWallet, + newLevel, + totalXp, + ); + } } diff --git a/backend/src/blockchain/providers/sync-xp-milestone.provider.spec.ts b/backend/src/blockchain/providers/sync-xp-milestone.provider.spec.ts new file mode 100644 index 00000000..69f0ea9e --- /dev/null +++ b/backend/src/blockchain/providers/sync-xp-milestone.provider.spec.ts @@ -0,0 +1,211 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { SyncXpMilestoneProvider } from './sync-xp-milestone.provider'; +import * as StellarSdk from 'stellar-sdk'; + +// Mock StellarSdk +jest.mock('stellar-sdk', () => { + return { + rpc: { + Server: jest.fn().mockImplementation(() => ({ + getAccount: jest.fn(), + simulateTransaction: jest.fn(), + sendTransaction: jest.fn(), + getTransaction: jest.fn(), + })), + Api: { + isSimulationSuccess: jest.fn() as unknown as jest.Mock, + }, + assembleTransaction: jest.fn(), + }, + Contract: jest.fn().mockImplementation(() => ({ + call: jest.fn().mockReturnValue({}), + })), + Address: { + fromString: jest.fn().mockReturnValue({}), + }, + Keypair: { + fromSecret: jest.fn().mockImplementation(() => ({ + publicKey: jest.fn().mockReturnValue('GORACLE...'), + sign: 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(), + }; +}); + +describe('SyncXpMilestoneProvider', () => { + let provider: SyncXpMilestoneProvider; + let configService: ConfigService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SyncXpMilestoneProvider, + { + 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'; + if (key === 'SOROBAN_ADMIN_SECRET') return 'SORACLESECRET'; + return null; + }), + }, + }, + ], + }).compile(); + + provider = module.get(SyncXpMilestoneProvider); + configService = module.get(ConfigService); + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(provider).toBeDefined(); + }); + + describe('syncXpMilestone', () => { + const mockWallet = 'GABC123...'; + const mockNewLevel = 5; + const mockTotalXp = 2500; + + it('should successfully sync XP milestone on level-up', async () => { + const server = (provider as any).server; + + server.getAccount.mockResolvedValue({}); + server.simulateTransaction.mockResolvedValue({}); + (StellarSdk.rpc.Api.isSimulationSuccess as unknown as jest.Mock).mockReturnValue(true); + + const mockAssembledTx = { sign: jest.fn() }; + (StellarSdk.rpc.assembleTransaction as jest.Mock).mockReturnValue(mockAssembledTx); + + server.sendTransaction.mockResolvedValue({ status: 'PENDING', hash: 'TXHASH123' }); + server.getTransaction.mockResolvedValue({ status: 'SUCCESS' }); + + // Should not throw + await expect( + provider.syncXpMilestone(mockWallet, mockNewLevel, mockTotalXp), + ).resolves.toBeUndefined(); + + expect(server.getAccount).toHaveBeenCalled(); + expect(server.simulateTransaction).toHaveBeenCalled(); + expect(server.sendTransaction).toHaveBeenCalled(); + expect(mockAssembledTx.sign).toHaveBeenCalled(); + }); + + it('should not throw when contract ID is missing', async () => { + (provider as any).contractId = null; + + // Should silently return without throwing + await expect( + provider.syncXpMilestone(mockWallet, mockNewLevel, mockTotalXp), + ).resolves.toBeUndefined(); + + // No blockchain calls should be made + const server = (provider as any).server; + expect(server.getAccount).not.toHaveBeenCalled(); + }); + + it('should not throw when oracle secret is missing', async () => { + (provider as any).oracleSecret = null; + + await expect( + provider.syncXpMilestone(mockWallet, mockNewLevel, mockTotalXp), + ).resolves.toBeUndefined(); + + const server = (provider as any).server; + expect(server.getAccount).not.toHaveBeenCalled(); + }); + + it('should not throw when stellar wallet is empty', async () => { + await expect( + provider.syncXpMilestone('', mockNewLevel, mockTotalXp), + ).resolves.toBeUndefined(); + + const server = (provider as any).server; + expect(server.getAccount).not.toHaveBeenCalled(); + }); + + it('should not throw when simulation fails', async () => { + const server = (provider as any).server; + server.getAccount.mockResolvedValue({}); + server.simulateTransaction.mockResolvedValue({ error: 'Simulation failed' }); + (StellarSdk.rpc.Api.isSimulationSuccess as unknown as jest.Mock).mockReturnValue(false); + + // Should not throw - failures are logged and swallowed + await expect( + provider.syncXpMilestone(mockWallet, mockNewLevel, mockTotalXp), + ).resolves.toBeUndefined(); + }); + + it('should not throw when transaction submission fails', async () => { + const server = (provider as any).server; + server.getAccount.mockResolvedValue({}); + server.simulateTransaction.mockResolvedValue({}); + (StellarSdk.rpc.Api.isSimulationSuccess as unknown as jest.Mock).mockReturnValue(true); + (StellarSdk.rpc.assembleTransaction as jest.Mock).mockReturnValue({ sign: jest.fn() }); + server.sendTransaction.mockResolvedValue({ status: 'ERROR' }); + + await expect( + provider.syncXpMilestone(mockWallet, mockNewLevel, mockTotalXp), + ).resolves.toBeUndefined(); + }); + + it('should not throw when transaction fails after submission', async () => { + const server = (provider as any).server; + server.getAccount.mockResolvedValue({}); + server.simulateTransaction.mockResolvedValue({}); + (StellarSdk.rpc.Api.isSimulationSuccess as unknown as jest.Mock).mockReturnValue(true); + (StellarSdk.rpc.assembleTransaction as jest.Mock).mockReturnValue({ sign: jest.fn() }); + server.sendTransaction.mockResolvedValue({ status: 'PENDING', hash: 'TXHASH' }); + server.getTransaction.mockResolvedValue({ status: 'FAILED' }); + + await expect( + provider.syncXpMilestone(mockWallet, mockNewLevel, mockTotalXp), + ).resolves.toBeUndefined(); + }); + + it('should not throw when an unexpected error occurs', async () => { + const server = (provider as any).server; + server.getAccount.mockRejectedValue(new Error('Network error')); + + // Should catch error and not rethrow + await expect( + provider.syncXpMilestone(mockWallet, mockNewLevel, mockTotalXp), + ).resolves.toBeUndefined(); + }); + + it('should handle assembled transaction with build method', async () => { + const server = (provider as any).server; + server.getAccount.mockResolvedValue({}); + server.simulateTransaction.mockResolvedValue({}); + (StellarSdk.rpc.Api.isSimulationSuccess as unknown as jest.Mock).mockReturnValue(true); + + // Return a TransactionBuilder-like object (has build, not sign) + const mockBuiltTx = { sign: jest.fn() }; + const mockAssembledTxBuilder = { + build: jest.fn().mockReturnValue(mockBuiltTx), + }; + (StellarSdk.rpc.assembleTransaction as jest.Mock).mockReturnValue(mockAssembledTxBuilder); + + server.sendTransaction.mockResolvedValue({ status: 'PENDING', hash: 'TXHASH' }); + server.getTransaction.mockResolvedValue({ status: 'SUCCESS' }); + + await provider.syncXpMilestone(mockWallet, mockNewLevel, mockTotalXp); + + expect(mockAssembledTxBuilder.build).toHaveBeenCalled(); + expect(mockBuiltTx.sign).toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/src/blockchain/providers/sync-xp-milestone.provider.ts b/backend/src/blockchain/providers/sync-xp-milestone.provider.ts new file mode 100644 index 00000000..8d4b9bd3 --- /dev/null +++ b/backend/src/blockchain/providers/sync-xp-milestone.provider.ts @@ -0,0 +1,177 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as StellarSdk from 'stellar-sdk'; + +@Injectable() +export class SyncXpMilestoneProvider { + private readonly logger = new Logger(SyncXpMilestoneProvider.name); + private readonly server: StellarSdk.rpc.Server; + private readonly contractId: string | undefined; + private readonly oracleSecret: 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'); + this.oracleSecret = this.configService.get('SOROBAN_ADMIN_SECRET'); + } + + /** + * Syncs an XP milestone (level-up event) to the Soroban smart contract. + * Uses update_iq_level contract function to record the milestone on-chain. + * + * This method is fire-and-forget: failures are logged but never thrown + * to ensure they don't affect the user's XP update in the database. + * + * @param stellarWallet The player's Stellar wallet address. + * @param newLevel The player's new level after level-up. + * @param totalXp The player's total XP at time of level-up. + */ + async syncXpMilestone( + stellarWallet: string, + newLevel: number, + totalXp: number, + ): Promise { + try { + if (!this.contractId) { + this.logger.warn( + `Cannot sync XP milestone: SOROBAN_CONTRACT_ID not configured. ` + + `Wallet: ${stellarWallet}, Level: ${newLevel}, XP: ${totalXp}`, + ); + return; + } + + if (!this.oracleSecret) { + this.logger.warn( + `Cannot sync XP milestone: SOROBAN_ADMIN_SECRET not configured. ` + + `Wallet: ${stellarWallet}, Level: ${newLevel}, XP: ${totalXp}`, + ); + return; + } + + if (!stellarWallet) { + this.logger.warn( + `Cannot sync XP milestone: No Stellar wallet provided. ` + + `Level: ${newLevel}, XP: ${totalXp}`, + ); + return; + } + + this.logger.log( + `Syncing XP milestone to blockchain: ` + + `Wallet: ${stellarWallet}, Level: ${newLevel}, XP: ${totalXp}`, + ); + + // 1. Prepare oracle keypair and player address + const oracleKeypair = StellarSdk.Keypair.fromSecret(this.oracleSecret); + const playerAddress = StellarSdk.Address.fromString(stellarWallet); + const contract = new StellarSdk.Contract(this.contractId); + + // 2. Fetch source account details + const sourceAccount = await this.server.getAccount(oracleKeypair.publicKey()); + + // 3. Build the transaction calling update_iq_level + // This is the closest existing contract function for milestone updates + // until a dedicated milestone function is added in v2 + const transaction = new StellarSdk.TransactionBuilder(sourceAccount, { + fee: '1000', + networkPassphrase: StellarSdk.Networks.TESTNET, + }) + .addOperation( + contract.call( + 'update_iq_level', + StellarSdk.nativeToScVal(playerAddress, { type: 'address' }), + StellarSdk.nativeToScVal(newLevel, { type: 'u32' }), + ), + ) + .setTimeout(StellarSdk.TimeoutInfinite) + .build(); + + // 4. Simulate the transaction + const simulation = await this.server.simulateTransaction(transaction); + + if (!StellarSdk.rpc.Api.isSimulationSuccess(simulation)) { + const errorMsg = (simulation as any).error || 'Unknown simulation error'; + this.logger.error( + `XP milestone simulation failed for ${stellarWallet}: ${errorMsg}. ` + + `Level: ${newLevel}, XP: ${totalXp}`, + ); + return; + } + + // 5. Assemble and sign the transaction + const assembledTransaction: any = StellarSdk.rpc.assembleTransaction( + transaction, + simulation, + ); + + let txToSign: any; + if (typeof assembledTransaction.sign === 'function') { + txToSign = assembledTransaction; + } else if (typeof assembledTransaction.build === 'function') { + txToSign = assembledTransaction.build(); + } else { + this.logger.error( + `XP milestone assembly failed for ${stellarWallet}: Invalid transaction type. ` + + `Level: ${newLevel}, XP: ${totalXp}`, + ); + return; + } + + txToSign.sign(oracleKeypair); + + // 6. Submit the transaction + const result: any = await this.server.sendTransaction(txToSign); + + if (result.status === 'PENDING') { + const txHash = result.hash; + this.logger.log( + `XP milestone transaction submitted. Hash: ${txHash}, ` + + `Wallet: ${stellarWallet}, Level: ${newLevel}`, + ); + + // Poll for result + let txResult: any = await this.server.getTransaction(txHash); + let pollCount = 0; + const maxPolls = 30; // Max 30 seconds of polling + + while ( + (txResult.status === 'NOT_FOUND' || txResult.status === 'PENDING') && + pollCount < maxPolls + ) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + txResult = await this.server.getTransaction(txHash); + pollCount++; + } + + if (txResult.status === 'SUCCESS') { + this.logger.log( + `XP milestone synced successfully: ` + + `Wallet: ${stellarWallet}, Level: ${newLevel}, XP: ${totalXp}, Hash: ${txHash}`, + ); + return; + } else { + this.logger.error( + `XP milestone transaction failed for ${stellarWallet}: ` + + `Status: ${txResult.status}, Level: ${newLevel}, XP: ${totalXp}`, + ); + return; + } + } + + this.logger.error( + `XP milestone submission failed for ${stellarWallet}: ` + + `Status: ${result.status}, Level: ${newLevel}, XP: ${totalXp}`, + ); + } catch (error) { + // Never throw - log and continue + this.logger.error( + `Error syncing XP milestone for ${stellarWallet}: ${error.message}. ` + + `Level: ${newLevel}, XP: ${totalXp}`, + error.stack, + ); + } + } +} diff --git a/backend/src/users/providers/xp-level.service.ts b/backend/src/users/providers/xp-level.service.ts index 0ef048f0..14037575 100644 --- a/backend/src/users/providers/xp-level.service.ts +++ b/backend/src/users/providers/xp-level.service.ts @@ -1,15 +1,18 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from '../user.entity'; +import { BlockchainService } from '../../blockchain/provider/blockchain.service'; @Injectable() export class XpLevelService { + private readonly logger = new Logger(XpLevelService.name); private readonly XP_PER_LEVEL = 500; constructor( @InjectRepository(User) private readonly userRepository: Repository, + private readonly blockchainService: BlockchainService, ) {} /** @@ -48,6 +51,19 @@ export class XpLevelService { await this.userRepository.save(user); + // Sync XP milestone to blockchain if level-up occurred + // This is fire-and-forget - failures are logged but don't affect the XP update + if (levelUp && user.stellarWallet) { + this.blockchainService + .syncXpMilestone(user.stellarWallet, user.level, user.xp) + .catch((error) => { + this.logger.error( + `Failed to sync XP milestone for user ${userId}: ${error.message}`, + error.stack, + ); + }); + } + return { levelUp, currentLevel: user.level, diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index b47ff044..4041fee3 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -17,12 +17,14 @@ import { UserProgress } from '../progress/entities/progress.entity'; import { Streak } from '../streak/entities/streak.entity'; import { DailyQuest } from '../quests/entities/daily-quest.entity'; import { XpLevelService } from './providers/xp-level.service'; +import { BlockchainModule } from '../blockchain/blockchain.module'; @Module({ imports: [ forwardRef(() => AuthModule), TypeOrmModule.forFeature([User, UserProgress, Streak, DailyQuest]), PaginationModule, + BlockchainModule, ], controllers: [UsersController], providers: [