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
3 changes: 2 additions & 1 deletion backend/src/blockchain/blockchain.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
12 changes: 12 additions & 0 deletions backend/src/blockchain/provider/blockchain.service.ts
Original file line number Diff line number Diff line change
@@ -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<object | null> {
return this.getPlayerProvider.getPlayerOnChain(stellarWallet);
}
}
151 changes: 151 additions & 0 deletions backend/src/blockchain/providers/get-player.provider.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(GetPlayerProvider);
configService = module.get<ConfigService>(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();
});
});
});
88 changes: 88 additions & 0 deletions backend/src/blockchain/providers/get-player.provider.ts
Original file line number Diff line number Diff line change
@@ -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<string>('SOROBAN_RPC_URL') ||
'https://soroban-testnet.stellar.org';
this.server = new StellarSdk.rpc.Server(rpcUrl);
this.contractId = this.configService.get<string>('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<object | null> {
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;
}
}
}
2 changes: 1 addition & 1 deletion backend/src/progress/entities/progress.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand Down
38 changes: 0 additions & 38 deletions backend/src/progress/entities/user-progress.entity.ts

This file was deleted.

Loading
Loading