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
35 changes: 35 additions & 0 deletions src/migrations/1751199300000-AddSellerSorobanFields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';

export class AddSellerSorobanFields1751199300000 implements MigrationInterface {
name = 'AddSellerSorobanFields1751199300000';

public async up(queryRunner: QueryRunner): Promise<void> {
// Add payout_wallet column
await queryRunner.addColumn(
'users',
new TableColumn({
name: 'payout_wallet',
type: 'varchar',
length: '255',
isNullable: true,
isUnique: true,
})
);

// Add seller_onchain_registered column
await queryRunner.addColumn(
'users',
new TableColumn({
name: 'seller_onchain_registered',
type: 'boolean',
default: false,
isNullable: false,
})
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('users', 'seller_onchain_registered');
await queryRunner.dropColumn('users', 'payout_wallet');
}
}
106 changes: 106 additions & 0 deletions src/modules/seller/controllers/seller.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import {
Controller,
Post,
Body,
UseGuards,
Request,
HttpCode,
HttpStatus,
Get,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../../auth/guards/roles.guard';
import { Roles } from '../../auth/decorators/roles.decorator';
import { SellerService } from '../services/seller.service';
import { BuildRegisterDto, BuildRegisterResponseDto } from '../dto/build-register.dto';
import { SubmitRegisterDto, SubmitRegisterResponseDto } from '../dto/submit-register.dto';
import { AuthenticatedRequest } from '../../../types/auth-request.type';

@ApiTags('seller')
@Controller('seller/contract')
@UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth()
export class SellerController {
constructor(private readonly sellerService: SellerService) {}

@Post('build-register')
@Roles('seller')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Build unsigned XDR for seller registration',
description: 'Creates an unsigned XDR transaction for registering seller on Soroban blockchain',
})
@ApiResponse({
status: 200,
description: 'Unsigned XDR built successfully',
type: BuildRegisterResponseDto,
})
@ApiResponse({
status: 400,
description: 'Invalid request or user not eligible',
})
@ApiResponse({
status: 409,
description: 'User already has a payout wallet registered',
})
async buildRegister(
@Body() buildRegisterDto: BuildRegisterDto,
@Request() req: AuthenticatedRequest,
): Promise<BuildRegisterResponseDto> {
const result = await this.sellerService.buildRegister(Number(req.user.id), buildRegisterDto);

return {
success: true,
data: result,
};
}

@Post('submit')
@Roles('seller')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Submit signed XDR for seller registration',
description: 'Submits signed XDR to Soroban network and updates user registration status',
})
@ApiResponse({
status: 200,
description: 'Registration completed successfully',
type: SubmitRegisterResponseDto,
})
@ApiResponse({
status: 400,
description: 'Invalid signed XDR or user not eligible',
})
async submitRegister(
@Body() submitRegisterDto: SubmitRegisterDto,
@Request() req: AuthenticatedRequest,
): Promise<SubmitRegisterResponseDto> {
const result = await this.sellerService.submitRegister(Number(req.user.id), submitRegisterDto);

return {
success: true,
data: result,
};
}

@Get('status')
@Roles('seller')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Get seller registration status',
description: 'Returns the current registration status of the seller',
})
@ApiResponse({
status: 200,
description: 'Registration status retrieved successfully',
})
async getRegistrationStatus(@Request() req: AuthenticatedRequest) {
const result = await this.sellerService.getRegistrationStatus(Number(req.user.id));

return {
success: true,
data: result,
};
}
}
32 changes: 32 additions & 0 deletions src/modules/seller/dto/build-register.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, Matches } from 'class-validator';

export class BuildRegisterDto {
@ApiProperty({
description: 'Stellar payout wallet address for the seller',
example: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX',
})
@IsString()
@IsNotEmpty()
@Matches(/^G[A-Z2-7]{55}$/, {
message: 'Invalid Stellar wallet address format',
})
payoutWallet: string;
}

export class BuildRegisterResponseDto {
@ApiProperty({
description: 'Success status',
example: true,
})
success: boolean;

@ApiProperty({
description: 'Unsigned XDR transaction for Soroban contract registration',
example: 'AAAAAgAAAABqjgAAAAAA...',
})
data: {
unsignedXdr: string;
contractAddress: string;
};
}
30 changes: 30 additions & 0 deletions src/modules/seller/dto/submit-register.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty } from 'class-validator';

export class SubmitRegisterDto {
@ApiProperty({
description: 'Signed XDR transaction for Soroban contract registration',
example: 'AAAAAgAAAABqjgAAAAAA...',
})
@IsString()
@IsNotEmpty()
signedXdr: string;
}

export class SubmitRegisterResponseDto {
@ApiProperty({
description: 'Success status',
example: true,
})
success: boolean;

@ApiProperty({
description: 'Registration result with transaction hash',
})
data: {
transactionHash: string;
contractId: string;
payoutWallet: string;
registered: boolean;
};
}
17 changes: 17 additions & 0 deletions src/modules/seller/seller.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '../users/entities/user.entity';
import { SellerController } from './controllers/seller.controller';
import { SellerService } from './services/seller.service';
import { SharedModule } from '../shared/shared.module';

@Module({
imports: [
TypeOrmModule.forFeature([User]),
SharedModule,
],
controllers: [SellerController],
providers: [SellerService],
exports: [SellerService],
})
export class SellerModule {}
149 changes: 149 additions & 0 deletions src/modules/seller/services/seller.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { Injectable, BadRequestException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { BuildRegisterDto } from '../dto/build-register.dto';
import { SubmitRegisterDto } from '../dto/submit-register.dto';

@Injectable()
export class SellerService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}

/**
* Build unsigned XDR for seller registration on Soroban
*/
async buildRegister(userId: number, buildRegisterDto: BuildRegisterDto) {
const user = await this.userRepository.findOne({
where: { id: userId },
relations: ['userRoles', 'userRoles.role'],
});

if (!user) {
throw new BadRequestException('User not found');
}

// Check if user has seller role
const hasSellerRole = user.userRoles?.some(
(userRole) => userRole.role.name === 'seller'
);

if (!hasSellerRole) {
throw new BadRequestException('User must have seller role');
}

// Check if user already has a payout wallet registered
if (user.payoutWallet) {
throw new ConflictException('User already has a payout wallet registered');
}

// Check if the payout wallet is already used by another user
const existingUser = await this.userRepository.findOne({
where: { payoutWallet: buildRegisterDto.payoutWallet },
});

if (existingUser) {
throw new ConflictException('Payout wallet already registered by another user');
}

// TODO: Integrate with Soroban SDK to build actual XDR
// For now, return mock data
const mockUnsignedXdr = this.generateMockXdr(user.walletAddress, buildRegisterDto.payoutWallet);
const contractAddress = 'CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';

return {
unsignedXdr: mockUnsignedXdr,
contractAddress,
};
}

/**
* Submit signed XDR and update user registration status
*/
async submitRegister(userId: number, submitRegisterDto: SubmitRegisterDto) {
const user = await this.userRepository.findOne({
where: { id: userId },
relations: ['userRoles', 'userRoles.role'],
});

if (!user) {
throw new BadRequestException('User not found');
}

// Check if user has seller role
const hasSellerRole = user.userRoles?.some(
(userRole) => userRole.role.name === 'seller'
);

if (!hasSellerRole) {
throw new BadRequestException('User must have seller role');
}

// TODO: Validate signed XDR and submit to Soroban network
// TODO: Extract payout wallet from XDR transaction
// For now, use mock validation and data

if (!this.validateSignedXdr(submitRegisterDto.signedXdr)) {
throw new BadRequestException('Invalid signed XDR');
}

const mockPayoutWallet = this.extractPayoutWalletFromXdr(submitRegisterDto.signedXdr);
const mockTransactionHash = this.generateMockTransactionHash();
const mockContractId = 'CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';

// Update user with payout wallet and registration status
await this.userRepository.update(userId, {
payoutWallet: mockPayoutWallet,
sellerOnchainRegistered: true,
});

return {
transactionHash: mockTransactionHash,
contractId: mockContractId,
payoutWallet: mockPayoutWallet,
registered: true,
};
}

/**
* Get seller registration status
*/
async getRegistrationStatus(userId: number) {
const user = await this.userRepository.findOne({
where: { id: userId },
select: ['id', 'payoutWallet', 'sellerOnchainRegistered'],
});

if (!user) {
throw new BadRequestException('User not found');
}

return {
isRegistered: user.sellerOnchainRegistered,
payoutWallet: user.payoutWallet,
};
}

// Mock methods - replace with actual Soroban integration
private generateMockXdr(walletAddress: string, payoutWallet: string): string {
const mockData = `${walletAddress}:${payoutWallet}:${Date.now()}`;
return Buffer.from(mockData).toString('base64');
}

private validateSignedXdr(signedXdr: string): boolean {
// TODO: Implement actual XDR validation with Soroban SDK
return signedXdr && signedXdr.length > 10;
}

private extractPayoutWalletFromXdr(signedXdr: string): string {
// TODO: Extract actual payout wallet from XDR
// For now, return a mock wallet
return 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';
}

private generateMockTransactionHash(): string {
return Array(64).fill(0).map(() => Math.floor(Math.random() * 16).toString(16)).join('');
}
}
Loading