Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion middleware/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
43 changes: 43 additions & 0 deletions middleware/src/blockchain/blockchain.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
103 changes: 103 additions & 0 deletions middleware/src/blockchain/blockchain.service.ts
Original file line number Diff line number Diff line change
@@ -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<object | null> {
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<void> {
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<void> {
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<void> {
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<void> {
return this.syncStreakProvider.syncStreakOnChain(
stellarWallet,
currentStreak,
);
}
}
13 changes: 13 additions & 0 deletions middleware/src/blockchain/index.ts
Original file line number Diff line number Diff line change
@@ -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';
153 changes: 153 additions & 0 deletions middleware/src/blockchain/link-wallet.provider.ts
Original file line number Diff line number Diff line change
@@ -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<LinkWalletUser | null>;
/**
* Returns the user whose stellarWallet matches the given address,
* or null if no user owns that wallet.
*/
findUserByWallet: (wallet: string) => Promise<LinkWalletUser | null>;
/** Persists the stellarWallet on the user record and returns the updated user. */
saveWallet: (
userId: string,
stellarWallet: string,
) => Promise<LinkWalletUser>;
}

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<LinkWalletResult> {
// 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;
}
}
}
Loading
Loading