diff --git a/middleware/package.json b/middleware/package.json index d8d4e93..0ba0c3a 100644 --- a/middleware/package.json +++ b/middleware/package.json @@ -17,13 +17,15 @@ }, "dependencies": { "@nestjs/common": "^11.0.12", + "@nestjs/config": "^4.0.0", "@types/micromatch": "^4.0.10", "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "express": "^5.1.0", "jsonwebtoken": "^9.0.2", - "micromatch": "^4.0.8" + "micromatch": "^4.0.8", + "stellar-sdk": "^13.1.0" }, "devDependencies": { "@types/express": "^5.0.0", diff --git a/middleware/src/blockchain/blockchain.module.ts b/middleware/src/blockchain/blockchain.module.ts new file mode 100644 index 0000000..4ff9d8a --- /dev/null +++ b/middleware/src/blockchain/blockchain.module.ts @@ -0,0 +1,43 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { BlockchainService } from './blockchain.service'; +import { GetPlayerProvider } from './providers/get-player.provider'; +import { RegisterPlayerProvider } from './providers/register-player.provider'; +import { SubmitPuzzleProvider } from './providers/submit-puzzle.provider'; +import { SyncXpMilestoneProvider } from './providers/sync-xp-milestone.provider'; +import { SyncStreakProvider } from './providers/sync-streak.provider'; + +/** + * BlockchainModule — Issue #307 + * + * Registers all four Soroban contract providers and exports BlockchainService + * so it is injectable across every dependent module: + * - ProgressModule → submitPuzzleOnChain after correct answer (Issue #309) + * - UsersModule → registerPlayerOnChain after wallet link (Issue #308) + * - QuestsModule → syncXpMilestone after daily quest level-up (Issue #309) + * - StreakModule → syncStreakOnChain after streak update (Issue #310) + * + * Usage in dependent modules: + * imports: [BlockchainModule] ← import the module + * // DO NOT add BlockchainService to providers — it is exported by this module + * + * Required environment variables (accessed via ConfigModule): + * STELLAR_SECRET_KEY + * STELLAR_CONTRACT_ID + * STELLAR_RPC_URL + * STELLAR_NETWORK_PASSPHRASE + * STREAK_SYNC_ENABLED + */ +@Module({ + imports: [ConfigModule], + providers: [ + BlockchainService, + GetPlayerProvider, + RegisterPlayerProvider, + SubmitPuzzleProvider, + SyncXpMilestoneProvider, + SyncStreakProvider, + ], + exports: [BlockchainService], +}) +export class BlockchainModule {} diff --git a/middleware/src/blockchain/blockchain.service.ts b/middleware/src/blockchain/blockchain.service.ts new file mode 100644 index 0000000..0ddf165 --- /dev/null +++ b/middleware/src/blockchain/blockchain.service.ts @@ -0,0 +1,103 @@ +import { Injectable } from '@nestjs/common'; +import { GetPlayerProvider } from './providers/get-player.provider'; +import { RegisterPlayerProvider } from './providers/register-player.provider'; +import { SubmitPuzzleProvider } from './providers/submit-puzzle.provider'; +import { SyncXpMilestoneProvider } from './providers/sync-xp-milestone.provider'; +import { SyncStreakProvider } from './providers/sync-streak.provider'; + +/** + * BlockchainService — Issue #307 + * + * Central passthrough service that exposes all Soroban contract interactions. + * Inject this service into any NestJS provider that needs blockchain access + * (ProgressCalculationProvider, CompleteDailyQuestProvider, UpdateStreakProvider, + * StellarWalletLoginProvider, LinkWalletProvider). + * + * Environment variables required: + * STELLAR_SECRET_KEY — Oracle wallet secret key (signs transactions) + * STELLAR_CONTRACT_ID — Deployed Soroban contract ID + * STELLAR_RPC_URL — Stellar RPC endpoint (default: testnet) + * STELLAR_NETWORK_PASSPHRASE — Network passphrase (default: testnet) + * STREAK_SYNC_ENABLED — true | false (gates syncStreakOnChain) + */ +@Injectable() +export class BlockchainService { + constructor( + private readonly getPlayerProvider: GetPlayerProvider, + private readonly registerPlayerProvider: RegisterPlayerProvider, + private readonly submitPuzzleProvider: SubmitPuzzleProvider, + private readonly syncXpMilestoneProvider: SyncXpMilestoneProvider, + private readonly syncStreakProvider: SyncStreakProvider, + ) {} + + /** + * Fetches a player's on-chain profile (read-only simulation). + */ + async getPlayerOnChain(stellarWallet: string): Promise { + return this.getPlayerProvider.getPlayerOnChain(stellarWallet); + } + + /** + * Registers a new player on the smart contract. + * Called after wallet linking or first-time wallet login (Issue #308). + */ + async registerPlayerOnChain( + stellarWallet: string, + username: string, + iqLevel: number, + ): Promise { + return this.registerPlayerProvider.registerPlayerOnChain( + stellarWallet, + username, + iqLevel, + ); + } + + /** + * Records a correct puzzle submission on the smart contract (Issue #309). + * Score must be normalized to a 0–100 scale before calling. + */ + async submitPuzzleOnChain( + stellarWallet: string, + puzzleId: string, + categoryId: string, + score: number, + ): Promise { + return this.submitPuzzleProvider.submitPuzzleOnChain( + stellarWallet, + puzzleId, + categoryId, + score, + ); + } + + /** + * Syncs a player's XP milestone (level-up) to the smart contract (Issue #309, #307). + * Called from CompleteDailyQuestProvider when level changes after bonus XP. + */ + async syncXpMilestone( + stellarWallet: string, + newLevel: number, + totalXp: number, + ): Promise { + return this.syncXpMilestoneProvider.syncXpMilestone( + stellarWallet, + newLevel, + totalXp, + ); + } + + /** + * Pushes a verified Postgres streak count to the smart contract (Issue #310). + * Gated behind STREAK_SYNC_ENABLED until the contract exposes sync_streak. + */ + async syncStreakOnChain( + stellarWallet: string, + currentStreak: number, + ): Promise { + return this.syncStreakProvider.syncStreakOnChain( + stellarWallet, + currentStreak, + ); + } +} diff --git a/middleware/src/blockchain/index.ts b/middleware/src/blockchain/index.ts new file mode 100644 index 0000000..1a5f8bf --- /dev/null +++ b/middleware/src/blockchain/index.ts @@ -0,0 +1,13 @@ +// Issue #307 — BlockchainModule and BlockchainService +export * from './blockchain.module'; +export * from './blockchain.service'; + +// Providers +export * from './providers/get-player.provider'; +export * from './providers/register-player.provider'; +export * from './providers/submit-puzzle.provider'; +export * from './providers/sync-xp-milestone.provider'; +export * from './providers/sync-streak.provider'; + +// Issue #308 — Wallet linking provider and its interfaces +export * from './link-wallet.provider'; diff --git a/middleware/src/blockchain/link-wallet.provider.ts b/middleware/src/blockchain/link-wallet.provider.ts new file mode 100644 index 0000000..4e87ba9 --- /dev/null +++ b/middleware/src/blockchain/link-wallet.provider.ts @@ -0,0 +1,153 @@ +import { + Injectable, + Logger, + BadRequestException, + ConflictException, + NotFoundException, +} from '@nestjs/common'; +import * as StellarSdk from 'stellar-sdk'; +import { BlockchainService } from './blockchain.service'; + +/** + * LinkWalletOptions defines the shape of the user record the host + * application passes in. The middleware stays agnostic of the ORM. + */ +export interface LinkWalletUser { + /** Unique user identifier */ + id: string; + /** Player username passed to the contract */ + username: string; + /** Current level used as iq_level on the contract */ + level: number; + /** Existing stellarWallet value — null/undefined means not yet linked */ + stellarWallet?: string | null; +} + +/** + * LinkWalletCallbacks provides two async functions the host application + * supplies so the middleware can look up users and persist the wallet + * without depending on any specific ORM or database layer. + */ +export interface LinkWalletCallbacks { + /** Returns the user identified by id, or null if not found. */ + findUserById: (id: string) => Promise; + /** + * Returns the user whose stellarWallet matches the given address, + * or null if no user owns that wallet. + */ + findUserByWallet: (wallet: string) => Promise; + /** Persists the stellarWallet on the user record and returns the updated user. */ + saveWallet: ( + userId: string, + stellarWallet: string, + ) => Promise; +} + +export interface LinkWalletResult { + success: boolean; + message: string; + stellarWallet: string; +} + +/** + * LinkWalletProvider — Issue #308 + * + * Implements the PATCH /users/link-wallet business logic: + * 1. Validates the Stellar address format (Ed25519 public key). + * 2. Ensures the wallet is not already linked to another account. + * 3. Saves the wallet to the user record via the provided callback. + * 4. Fires registerPlayerOnChain non-blocking after the DB save. + * + * The provider is ORM-agnostic — the host module supplies LinkWalletCallbacks + * so this middleware works with any persistence layer. + * + * Usage in a NestJS controller: + * + * @Patch('link-wallet') + * @UseGuards(AuthGuard('jwt')) + * async linkWallet(@ActiveUser('sub') userId: string, @Body() dto: { stellarWallet: string }) { + * return this.linkWalletProvider.execute(userId, dto.stellarWallet, { + * findUserById: (id) => this.usersService.findOneById(id), + * findUserByWallet:(wallet) => this.usersService.getOneByWallet(wallet), + * saveWallet: (id, w) => this.usersService.updateWallet(id, w), + * }); + * } + */ +@Injectable() +export class LinkWalletProvider { + private readonly logger = new Logger(LinkWalletProvider.name); + + constructor(private readonly blockchainService: BlockchainService) {} + + /** + * Executes the wallet linking flow. + * + * @param userId - Authenticated user's ID from the JWT payload. + * @param stellarWallet - Stellar public key the user wants to link. + * @param callbacks - ORM-agnostic DB access functions supplied by the host. + */ + async execute( + userId: string, + stellarWallet: string, + callbacks: LinkWalletCallbacks, + ): Promise { + // 1. Validate Stellar address format + if (!this.isValidStellarAddress(stellarWallet)) { + throw new BadRequestException( + 'Invalid Stellar wallet address. Must be a valid Ed25519 public key (starts with G, 56 characters).', + ); + } + + // 2. Load the requesting user + const user = await callbacks.findUserById(userId); + if (!user) { + throw new NotFoundException(`User ${userId} not found`); + } + + // 3. Check if the wallet is already linked to another account + const existingOwner = await callbacks.findUserByWallet(stellarWallet); + if (existingOwner && existingOwner.id !== userId) { + throw new ConflictException( + 'This Stellar wallet is already linked to a different account.', + ); + } + + // 4. Save the wallet to the user record + const updatedUser = await callbacks.saveWallet(userId, stellarWallet); + this.logger.log( + `Stellar wallet linked for user ${userId}: ${stellarWallet}`, + ); + + // 5. Trigger on-chain registration — non-blocking, must not affect the response + this.blockchainService + .registerPlayerOnChain( + stellarWallet, + updatedUser.username, + updatedUser.level, + ) + .catch((err) => + this.logger.error( + `registerPlayerOnChain failed after wallet link for user ${userId}: ${err.message}`, + err.stack, + ), + ); + + return { + success: true, + message: 'Stellar wallet linked successfully.', + stellarWallet, + }; + } + + /** + * Returns true when the address is a valid Stellar Ed25519 public key + * (starts with G, 56 alphanumeric characters). + */ + private isValidStellarAddress(address: string): boolean { + try { + return StellarSdk.StrKey.isValidEd25519PublicKey(address); + } catch { + return false; + } + } +} diff --git a/middleware/src/blockchain/providers/get-player.provider.ts b/middleware/src/blockchain/providers/get-player.provider.ts new file mode 100644 index 0000000..0dc481a --- /dev/null +++ b/middleware/src/blockchain/providers/get-player.provider.ts @@ -0,0 +1,94 @@ +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; + private readonly networkPassphrase: string; + + constructor(private readonly configService: ConfigService) { + const rpcUrl = + this.configService.get('STELLAR_RPC_URL') || + 'https://soroban-testnet.stellar.org'; + this.server = new StellarSdk.rpc.Server(rpcUrl); + this.contractId = this.configService.get('STELLAR_CONTRACT_ID'); + this.networkPassphrase = + this.configService.get('STELLAR_NETWORK_PASSPHRASE') || + StellarSdk.Networks.TESTNET; + } + + /** + * Fetches a player's on-chain profile from the Soroban smart contract. + * Read-only: uses simulation, no signing needed. + * + * @param stellarWallet - The player's Stellar public key address. + * @returns The player object if found on-chain, null otherwise. + */ + async getPlayerOnChain(stellarWallet: string): Promise { + try { + if (!this.contractId) { + this.logger.warn( + 'STELLAR_CONTRACT_ID not configured — skipping getPlayerOnChain', + ); + return null; + } + + const contract = new StellarSdk.Contract(this.contractId); + const address = StellarSdk.Address.fromString(stellarWallet); + + // Use a dummy source account for read-only simulation + const sourceAccount = new StellarSdk.Account( + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + '0', + ); + + const transaction = new StellarSdk.TransactionBuilder(sourceAccount, { + fee: '100', + networkPassphrase: this.networkPassphrase, + }) + .addOperation( + contract.call( + 'get_player', + StellarSdk.nativeToScVal(address, { type: 'address' }), + ), + ) + .setTimeout(StellarSdk.TimeoutInfinite) + .build(); + + const simulation = await this.server.simulateTransaction(transaction); + + if (StellarSdk.rpc.Api.isSimulationSuccess(simulation)) { + const resultVal = simulation.result?.retval; + + if ( + !resultVal || + resultVal.switch().value === + StellarSdk.xdr.ScValType.scvVoid().value + ) { + this.logger.debug(`Player ${stellarWallet} not found on-chain.`); + return null; + } + + const player = StellarSdk.scValToNative(resultVal); + this.logger.log( + `Successfully fetched on-chain profile for ${stellarWallet}`, + ); + return player; + } + + this.logger.warn( + `Simulation failed for get_player(${stellarWallet})`, + ); + return null; + } catch (error) { + this.logger.error( + `getPlayerOnChain failed for ${stellarWallet}: ${error.message}`, + error.stack, + ); + return null; + } + } +} diff --git a/middleware/src/blockchain/providers/register-player.provider.ts b/middleware/src/blockchain/providers/register-player.provider.ts new file mode 100644 index 0000000..bd24422 --- /dev/null +++ b/middleware/src/blockchain/providers/register-player.provider.ts @@ -0,0 +1,87 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as StellarSdk from 'stellar-sdk'; + +@Injectable() +export class RegisterPlayerProvider { + private readonly logger = new Logger(RegisterPlayerProvider.name); + private readonly server: StellarSdk.rpc.Server; + private readonly contractId: string | undefined; + private readonly networkPassphrase: string; + private readonly secretKey: string | undefined; + + constructor(private readonly configService: ConfigService) { + const rpcUrl = + this.configService.get('STELLAR_RPC_URL') || + 'https://soroban-testnet.stellar.org'; + this.server = new StellarSdk.rpc.Server(rpcUrl); + this.contractId = this.configService.get('STELLAR_CONTRACT_ID'); + this.networkPassphrase = + this.configService.get('STELLAR_NETWORK_PASSPHRASE') || + StellarSdk.Networks.TESTNET; + this.secretKey = this.configService.get('STELLAR_SECRET_KEY'); + } + + /** + * Registers a new player on the Soroban smart contract (Issue #308). + * Called after wallet linking or first-time wallet login. + * Non-blocking: errors are caught and logged without propagation. + * + * @param stellarWallet - The player's Stellar public key address. + * @param username - The player's username. + * @param iqLevel - The player's current level (used as iq_level on-chain). + */ + async registerPlayerOnChain( + stellarWallet: string, + username: string, + iqLevel: number, + ): Promise { + try { + if (!this.contractId || !this.secretKey) { + this.logger.warn( + 'STELLAR_CONTRACT_ID or STELLAR_SECRET_KEY not configured — skipping registerPlayerOnChain', + ); + return; + } + + const oracleKeypair = StellarSdk.Keypair.fromSecret(this.secretKey); + const oracleAccount = await this.server.getAccount( + oracleKeypair.publicKey(), + ); + const contract = new StellarSdk.Contract(this.contractId); + + const transaction = new StellarSdk.TransactionBuilder(oracleAccount, { + fee: StellarSdk.BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation( + contract.call( + 'register_player', + StellarSdk.nativeToScVal( + StellarSdk.Address.fromString(stellarWallet), + { type: 'address' }, + ), + StellarSdk.nativeToScVal(username, { type: 'string' }), + StellarSdk.nativeToScVal(iqLevel, { type: 'u32' }), + ), + ) + .setTimeout(30) + .build(); + + const prepared = await this.server.prepareTransaction(transaction); + (prepared as StellarSdk.Transaction).sign(oracleKeypair); + + const result = await this.server.sendTransaction( + prepared as StellarSdk.Transaction, + ); + this.logger.log( + `registerPlayerOnChain submitted for ${stellarWallet}: tx hash ${result.hash}`, + ); + } catch (error) { + this.logger.error( + `registerPlayerOnChain failed for ${stellarWallet}: ${error.message}`, + error.stack, + ); + } + } +} diff --git a/middleware/src/blockchain/providers/submit-puzzle.provider.ts b/middleware/src/blockchain/providers/submit-puzzle.provider.ts new file mode 100644 index 0000000..66b5fcb --- /dev/null +++ b/middleware/src/blockchain/providers/submit-puzzle.provider.ts @@ -0,0 +1,90 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as StellarSdk from 'stellar-sdk'; + +@Injectable() +export class SubmitPuzzleProvider { + private readonly logger = new Logger(SubmitPuzzleProvider.name); + private readonly server: StellarSdk.rpc.Server; + private readonly contractId: string | undefined; + private readonly networkPassphrase: string; + private readonly secretKey: string | undefined; + + constructor(private readonly configService: ConfigService) { + const rpcUrl = + this.configService.get('STELLAR_RPC_URL') || + 'https://soroban-testnet.stellar.org'; + this.server = new StellarSdk.rpc.Server(rpcUrl); + this.contractId = this.configService.get('STELLAR_CONTRACT_ID'); + this.networkPassphrase = + this.configService.get('STELLAR_NETWORK_PASSPHRASE') || + StellarSdk.Networks.TESTNET; + this.secretKey = this.configService.get('STELLAR_SECRET_KEY'); + } + + /** + * Records a puzzle submission on the Soroban smart contract (Issue #309). + * Called after a correct puzzle answer is verified and saved to Postgres. + * Non-blocking: errors are caught and logged without propagation. + * + * @param stellarWallet - The player's Stellar public key address. + * @param puzzleId - The puzzle UUID. + * @param categoryId - The puzzle category UUID. + * @param score - Points earned normalized to a 0–100 scale. + */ + async submitPuzzleOnChain( + stellarWallet: string, + puzzleId: string, + categoryId: string, + score: number, + ): Promise { + try { + if (!this.contractId || !this.secretKey) { + this.logger.warn( + 'STELLAR_CONTRACT_ID or STELLAR_SECRET_KEY not configured — skipping submitPuzzleOnChain', + ); + return; + } + + const oracleKeypair = StellarSdk.Keypair.fromSecret(this.secretKey); + const oracleAccount = await this.server.getAccount( + oracleKeypair.publicKey(), + ); + const contract = new StellarSdk.Contract(this.contractId); + + const transaction = new StellarSdk.TransactionBuilder(oracleAccount, { + fee: StellarSdk.BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation( + contract.call( + 'submit_puzzle', + StellarSdk.nativeToScVal( + StellarSdk.Address.fromString(stellarWallet), + { type: 'address' }, + ), + StellarSdk.nativeToScVal(puzzleId, { type: 'string' }), + StellarSdk.nativeToScVal(categoryId, { type: 'string' }), + StellarSdk.nativeToScVal(score, { type: 'u32' }), + ), + ) + .setTimeout(30) + .build(); + + const prepared = await this.server.prepareTransaction(transaction); + (prepared as StellarSdk.Transaction).sign(oracleKeypair); + + const result = await this.server.sendTransaction( + prepared as StellarSdk.Transaction, + ); + this.logger.log( + `submitPuzzleOnChain submitted for ${stellarWallet}, puzzle ${puzzleId}: tx hash ${result.hash}`, + ); + } catch (error) { + this.logger.error( + `submitPuzzleOnChain failed for ${stellarWallet}, puzzle ${puzzleId}: ${error.message}`, + error.stack, + ); + } + } +} diff --git a/middleware/src/blockchain/providers/sync-streak.provider.ts b/middleware/src/blockchain/providers/sync-streak.provider.ts new file mode 100644 index 0000000..23e9861 --- /dev/null +++ b/middleware/src/blockchain/providers/sync-streak.provider.ts @@ -0,0 +1,108 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as StellarSdk from 'stellar-sdk'; + +/** + * SyncStreakProvider — Issue #310 + * + * Pushes a verified Postgres streak count to the Soroban smart contract. + * The backend Postgres record is always the source of truth; the contract reflects it. + * + * DEPENDENCY NOTE: + * The Soroban contract currently has no dedicated streak-update function. + * This provider is fully implemented and wired, but the actual contract call + * is gated behind the `STREAK_SYNC_ENABLED` environment variable. + * Set STREAK_SYNC_ENABLED=true once the contract's `sync_streak` function + * is available (ref: contract Issue #2 — automatic streak reset) without + * requiring a backend redeployment. + */ +@Injectable() +export class SyncStreakProvider { + private readonly logger = new Logger(SyncStreakProvider.name); + private readonly server: StellarSdk.rpc.Server; + private readonly contractId: string | undefined; + private readonly networkPassphrase: string; + private readonly secretKey: string | undefined; + private readonly streakSyncEnabled: boolean; + + constructor(private readonly configService: ConfigService) { + const rpcUrl = + this.configService.get('STELLAR_RPC_URL') || + 'https://soroban-testnet.stellar.org'; + this.server = new StellarSdk.rpc.Server(rpcUrl); + this.contractId = this.configService.get('STELLAR_CONTRACT_ID'); + this.networkPassphrase = + this.configService.get('STELLAR_NETWORK_PASSPHRASE') || + StellarSdk.Networks.TESTNET; + this.secretKey = this.configService.get('STELLAR_SECRET_KEY'); + this.streakSyncEnabled = + this.configService.get('STREAK_SYNC_ENABLED') === 'true'; + } + + /** + * Syncs a player's current streak to the Soroban smart contract. + * Gated behind STREAK_SYNC_ENABLED until the contract exposes sync_streak. + * Non-blocking: errors are caught and logged without propagation. + * + * @param stellarWallet - The player's Stellar public key address. + * @param currentStreak - The verified current streak count from Postgres. + */ + async syncStreakOnChain( + stellarWallet: string, + currentStreak: number, + ): Promise { + if (!this.streakSyncEnabled) { + this.logger.debug( + `STREAK_SYNC_ENABLED=false — skipping syncStreakOnChain for ${stellarWallet}`, + ); + return; + } + + try { + if (!this.contractId || !this.secretKey) { + this.logger.warn( + 'STELLAR_CONTRACT_ID or STELLAR_SECRET_KEY not configured — skipping syncStreakOnChain', + ); + return; + } + + const oracleKeypair = StellarSdk.Keypair.fromSecret(this.secretKey); + const oracleAccount = await this.server.getAccount( + oracleKeypair.publicKey(), + ); + const contract = new StellarSdk.Contract(this.contractId); + + const transaction = new StellarSdk.TransactionBuilder(oracleAccount, { + fee: StellarSdk.BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation( + contract.call( + 'sync_streak', + StellarSdk.nativeToScVal( + StellarSdk.Address.fromString(stellarWallet), + { type: 'address' }, + ), + StellarSdk.nativeToScVal(currentStreak, { type: 'u32' }), + ), + ) + .setTimeout(30) + .build(); + + const prepared = await this.server.prepareTransaction(transaction); + (prepared as StellarSdk.Transaction).sign(oracleKeypair); + + const result = await this.server.sendTransaction( + prepared as StellarSdk.Transaction, + ); + this.logger.log( + `syncStreakOnChain submitted for ${stellarWallet}, streak ${currentStreak}: tx hash ${result.hash}`, + ); + } catch (error) { + this.logger.error( + `syncStreakOnChain failed for ${stellarWallet}: ${error.message}`, + error.stack, + ); + } + } +} diff --git a/middleware/src/blockchain/providers/sync-xp-milestone.provider.ts b/middleware/src/blockchain/providers/sync-xp-milestone.provider.ts new file mode 100644 index 0000000..e263fc1 --- /dev/null +++ b/middleware/src/blockchain/providers/sync-xp-milestone.provider.ts @@ -0,0 +1,87 @@ +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 networkPassphrase: string; + private readonly secretKey: string | undefined; + + constructor(private readonly configService: ConfigService) { + const rpcUrl = + this.configService.get('STELLAR_RPC_URL') || + 'https://soroban-testnet.stellar.org'; + this.server = new StellarSdk.rpc.Server(rpcUrl); + this.contractId = this.configService.get('STELLAR_CONTRACT_ID'); + this.networkPassphrase = + this.configService.get('STELLAR_NETWORK_PASSPHRASE') || + StellarSdk.Networks.TESTNET; + this.secretKey = this.configService.get('STELLAR_SECRET_KEY'); + } + + /** + * Syncs a player's XP milestone (level-up) to the Soroban smart contract (Issues #309, #307). + * Called after a level-up event is confirmed in Postgres. + * Non-blocking: errors are caught and logged without propagation. + * + * @param stellarWallet - The player's Stellar public key address. + * @param newLevel - The player's new level after the XP milestone. + * @param totalXp - The player's cumulative XP total. + */ + async syncXpMilestone( + stellarWallet: string, + newLevel: number, + totalXp: number, + ): Promise { + try { + if (!this.contractId || !this.secretKey) { + this.logger.warn( + 'STELLAR_CONTRACT_ID or STELLAR_SECRET_KEY not configured — skipping syncXpMilestone', + ); + return; + } + + const oracleKeypair = StellarSdk.Keypair.fromSecret(this.secretKey); + const oracleAccount = await this.server.getAccount( + oracleKeypair.publicKey(), + ); + const contract = new StellarSdk.Contract(this.contractId); + + const transaction = new StellarSdk.TransactionBuilder(oracleAccount, { + fee: StellarSdk.BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation( + contract.call( + 'sync_xp_milestone', + StellarSdk.nativeToScVal( + StellarSdk.Address.fromString(stellarWallet), + { type: 'address' }, + ), + StellarSdk.nativeToScVal(newLevel, { type: 'u32' }), + StellarSdk.nativeToScVal(totalXp, { type: 'u64' }), + ), + ) + .setTimeout(30) + .build(); + + const prepared = await this.server.prepareTransaction(transaction); + (prepared as StellarSdk.Transaction).sign(oracleKeypair); + + const result = await this.server.sendTransaction( + prepared as StellarSdk.Transaction, + ); + this.logger.log( + `syncXpMilestone submitted for ${stellarWallet}, level ${newLevel}: tx hash ${result.hash}`, + ); + } catch (error) { + this.logger.error( + `syncXpMilestone failed for ${stellarWallet}: ${error.message}`, + error.stack, + ); + } + } +} diff --git a/middleware/src/index.ts b/middleware/src/index.ts index fa1593c..088f941 100644 --- a/middleware/src/index.ts +++ b/middleware/src/index.ts @@ -15,3 +15,6 @@ export * from './middleware/utils/conditional.middleware'; // Advanced reliability middleware (#379) export * from './middleware/advanced/timeout.middleware'; export * from './middleware/advanced/circuit-breaker.middleware'; + +// Blockchain module — Issues #307, #308, #309, #310 +export * from './blockchain';