diff --git a/backend/.env.example b/backend/.env.example index c38966b3..05f6f062 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -21,5 +21,8 @@ SMTP_PASS= ASSET_ID_PREFIX=AST ASSET_ID_START=1000 -# File Upload Configuration -UPLOAD_DIR=/var/uploads/assets +# Stellar/Soroban Configuration +STELLAR_RPC_URL=https://rpc-futurenet.stellar.org +STELLAR_SECRET_KEY=your-stellar-secret-key-here +STELLAR_CONTRACT_ID=your-soroban-contract-id-here +STELLAR_NETWORK_PASSPHRASE=Future Net ; October 2022 diff --git a/backend/src/opsce/assets/entities/asset.entity.ts b/backend/src/opsce/assets/entities/asset.entity.ts index 8f0640e0..f2071009 100644 --- a/backend/src/opsce/assets/entities/asset.entity.ts +++ b/backend/src/opsce/assets/entities/asset.entity.ts @@ -507,6 +507,32 @@ export class Asset { @Column({ type: 'timestamptz', nullable: true }) statusChangedAt?: Date; + // ─── Tokenization ────────────────────────────────────────────────────────────── + + /** Stellar contract ID for tokenized assets */ + @Column({ type: 'text', nullable: true }) + stellarContractId?: string; + + /** Total number of shares for tokenized assets */ + @Column({ type: 'decimal', precision: 20, scale: 0, nullable: true }) + totalShares?: number; + + /** Whether this asset has been tokenized on Stellar */ + @Column({ default: false }) + isTokenized: boolean; + + /** Transaction hash of the tokenization transaction */ + @Column({ type: 'text', nullable: true }) + tokenizationTxHash?: string; + + /** Timestamp when the asset was tokenized */ + @Column({ type: 'timestamptz', nullable: true }) + tokenizedAt?: Date; + + /** Token symbol on Stellar network */ + @Column({ length: 50, nullable: true }) + tokenSymbol?: string; + // ─── Computed helpers ──────────────────────────────────────────────────────── get isDeleted(): boolean { diff --git a/backend/src/opsce/opsce.module.ts b/backend/src/opsce/opsce.module.ts index 23891321..1f80c30f 100644 --- a/backend/src/opsce/opsce.module.ts +++ b/backend/src/opsce/opsce.module.ts @@ -4,8 +4,7 @@ import { DepartmentsModule } from './departments/departments.module'; import { AuditModule } from './audit/audit.module'; import { UsersModule } from './users/users.module'; import { LocationsModule } from './locations/locations.module'; -import { AuthModule } from './auth/auth.module'; -import { UploadsModule } from './uploads/uploads.module'; +import { StellarModule } from './stellar/stellar.module'; /** * OpsceModule @@ -16,13 +15,12 @@ import { UploadsModule } from './uploads/uploads.module'; */ @Module({ imports: [ - AuthModule, UsersModule, LocationsModule, AuditModule, DepartmentsModule, AssetsModule, - UploadsModule, + StellarModule, ], exports: [ AuthModule, @@ -31,7 +29,7 @@ import { UploadsModule } from './uploads/uploads.module'; AuditModule, DepartmentsModule, AssetsModule, - UploadsModule, + StellarModule, ], }) export class OpsceModule {} \ No newline at end of file diff --git a/backend/src/opsce/stellar/dto/tokenize-asset.dto.ts b/backend/src/opsce/stellar/dto/tokenize-asset.dto.ts new file mode 100644 index 00000000..5d6a517a --- /dev/null +++ b/backend/src/opsce/stellar/dto/tokenize-asset.dto.ts @@ -0,0 +1,101 @@ +import { IsString, IsNumber, IsOptional, IsBoolean, IsArray, Min, IsObject } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class TokenizeAssetDto { + @ApiProperty({ + description: 'Token symbol for the asset (e.g., ASSET-001)', + example: 'ASSET-001', + }) + @IsString() + symbol: string; + + @ApiProperty({ + description: 'Total number of shares/tokens to create', + example: 10000, + minimum: 1, + }) + @IsNumber() + @Min(1) + totalShares: number; + + @ApiProperty({ + description: 'Price per share in the asset currency', + example: 100.5, + minimum: 0, + }) + @IsNumber() + @Min(0) + pricePerShare: number; + + @ApiPropertyOptional({ + description: 'Additional metadata for tokenization', + }) + @IsOptional() + @IsObject() + metadata?: TokenizationMetadataDto; +} + +export class TokenizationMetadataDto { + @ApiProperty({ + description: 'Name of the tokenized asset', + example: 'Premium Real Estate Token', + }) + @IsString() + name: string; + + @ApiProperty({ + description: 'Description of the tokenized asset', + example: 'Fractional ownership of a commercial property', + }) + @IsString() + description: string; + + @ApiPropertyOptional({ + description: 'Type of asset (physical or digital)', + enum: ['physical', 'digital'], + default: 'physical', + }) + @IsOptional() + @IsString() + assetType?: 'physical' | 'digital'; + + @ApiPropertyOptional({ + description: 'IPFS URI for extended metadata', + example: 'ipfs://QmXxx...', + }) + @IsOptional() + @IsString() + ipfsUri?: string; + + @ApiPropertyOptional({ + description: 'Hash of legal documentation (hex string)', + example: '0x1234abcd...', + }) + @IsOptional() + @IsString() + legalDocsHash?: string; + + @ApiPropertyOptional({ + description: 'Hash of valuation report (hex string)', + example: '0x5678efgh...', + }) + @IsOptional() + @IsString() + valuationReportHash?: string; + + @ApiPropertyOptional({ + description: 'Whether accredited investor status is required', + default: false, + }) + @IsOptional() + @IsBoolean() + accreditedInvestorRequired?: boolean; + + @ApiPropertyOptional({ + description: 'Geographic restrictions (ISO country codes)', + example: ['US', 'CA', 'UK'], + }) + @IsOptional() + @IsArray() + geographicRestrictions?: string[]; +} \ No newline at end of file diff --git a/backend/src/opsce/stellar/stellar.controller.ts b/backend/src/opsce/stellar/stellar.controller.ts new file mode 100644 index 00000000..e8bc8ed2 --- /dev/null +++ b/backend/src/opsce/stellar/stellar.controller.ts @@ -0,0 +1,250 @@ +import { + Controller, + Get, + Post, + Param, + Body, + UseGuards, + Logger, + HttpCode, + HttpStatus, + ParseUUIDPipe, + BadRequestException, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@nestjs/swagger'; +import { StellarService } from './stellar.service'; +import { TokenizeAssetDto } from './dto/tokenize-asset.dto'; +import { JwtAuthGuard } from '../users/guards/jwt-auth.guard'; // Assuming JWT auth guard exists +import { RolesGuard } from '../users/guards/roles.guard'; // Assuming roles guard exists +import { Roles } from '../users/decorators/roles.decorator'; // Assuming roles decorator exists +import { UserRole } from '../users/entities/user.entity'; + +@ApiTags('Asset Tokenization') +@Controller('api/assets') +@UseGuards(JwtAuthGuard, RolesGuard) +@ApiBearerAuth() +export class StellarController { + private readonly logger = new Logger(StellarController.name); + + constructor(private readonly stellarService: StellarService) {} + + @Post(':id/tokenize') + @HttpCode(HttpStatus.CREATED) + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Tokenize an asset on Stellar network' }) + @ApiParam({ name: 'id', description: 'Asset UUID', type: 'string' }) + @ApiResponse({ + status: 201, + description: 'Asset successfully tokenized', + schema: { + type: 'object', + properties: { + success: { type: 'boolean' }, + transactionHash: { type: 'string' }, + contractId: { type: 'string' }, + totalShares: { type: 'string' }, + assetId: { type: 'string' }, + }, + }, + }) + @ApiResponse({ status: 400, description: 'Bad request - Invalid input' }) + @ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing token' }) + @ApiResponse({ status: 403, description: 'Forbidden - Admin role required' }) + @ApiResponse({ status: 500, description: 'Internal server error' }) + async tokenizeAsset( + @Param('id', ParseUUIDPipe) assetId: string, + @Body() tokenizeDto: TokenizeAssetDto, + ) { + try { + this.logger.log(`Initiating tokenization for asset: ${assetId}`); + + const result = await this.stellarService.tokenizeAsset( + assetId, + tokenizeDto.symbol, + tokenizeDto.totalShares, + tokenizeDto.pricePerShare, + tokenizeDto.metadata, + ); + + this.logger.log(`Tokenization completed for asset: ${assetId}`); + + return { + success: true, + message: 'Asset tokenized successfully', + data: result, + }; + } catch (error) { + this.logger.error(`Tokenization failed for asset ${assetId}: ${error.message}`); + throw new BadRequestException({ + success: false, + message: 'Failed to tokenize asset', + error: error.message, + }); + } + } + + @Get(':id/tokenization') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Get tokenization information for an asset' }) + @ApiParam({ name: 'id', description: 'Asset UUID', type: 'string' }) + @ApiResponse({ + status: 200, + description: 'Tokenization information retrieved successfully', + schema: { + type: 'object', + properties: { + success: { type: 'boolean' }, + data: { + type: 'object', + properties: { + totalSupply: { type: 'string' }, + tokenHoldersCount: { type: 'number' }, + tokensInCirculation: { type: 'string' }, + symbol: { type: 'string' }, + decimals: { type: 'number' }, + tokenizer: { type: 'string' }, + valuation: { type: 'string' }, + holders: { + type: 'array', + items: { type: 'string' }, + }, + }, + }, + }, + }, + }) + @ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing token' }) + @ApiResponse({ status: 403, description: 'Forbidden - Admin role required' }) + @ApiResponse({ status: 404, description: 'Asset not tokenized' }) + async getTokenizationInfo(@Param('id', ParseUUIDPipe) assetId: string) { + try { + this.logger.log(`Fetching tokenization info for asset: ${assetId}`); + + // Check if asset is tokenized + const isTokenized = await this.stellarService.isAssetTokenized(assetId); + if (!isTokenized) { + return { + success: false, + message: 'Asset is not tokenized', + data: null, + }; + } + + // Get tokenization info + const tokenInfo = await this.stellarService.getTokenizationInfo(assetId); + + // Get token holders + const holders = await this.stellarService.getTokenHolders(assetId); + + this.logger.log(`Tokenization info retrieved for asset: ${assetId}`); + + return { + success: true, + data: { + ...tokenInfo, + holders, + }, + }; + } catch (error) { + this.logger.error(`Failed to get tokenization info for asset ${assetId}: ${error.message}`); + throw new BadRequestException({ + success: false, + message: 'Failed to retrieve tokenization information', + error: error.message, + }); + } + } + + @Get(':id/tokenization/holders') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Get token holders for an asset' }) + @ApiParam({ name: 'id', description: 'Asset UUID', type: 'string' }) + @ApiResponse({ + status: 200, + description: 'Token holders retrieved successfully', + schema: { + type: 'object', + properties: { + success: { type: 'boolean' }, + data: { + type: 'array', + items: { type: 'string' }, + }, + }, + }, + }) + @ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing token' }) + @ApiResponse({ status: 403, description: 'Forbidden - Admin role required' }) + async getTokenHolders(@Param('id', ParseUUIDPipe) assetId: string) { + try { + this.logger.log(`Fetching token holders for asset: ${assetId}`); + + const holders = await this.stellarService.getTokenHolders(assetId); + + return { + success: true, + data: holders, + }; + } catch (error) { + this.logger.error(`Failed to get token holders for asset ${assetId}: ${error.message}`); + throw new BadRequestException({ + success: false, + message: 'Failed to retrieve token holders', + error: error.message, + }); + } + } + + @Get(':id/tokenization/balance/:holderAddress') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Get token balance for a specific holder' }) + @ApiParam({ name: 'id', description: 'Asset UUID', type: 'string' }) + @ApiParam({ name: 'holderAddress', description: 'Stellar address of the holder', type: 'string' }) + @ApiResponse({ + status: 200, + description: 'Token balance retrieved successfully', + schema: { + type: 'object', + properties: { + success: { type: 'boolean' }, + data: { + type: 'object', + properties: { + holderAddress: { type: 'string' }, + balance: { type: 'string' }, + }, + }, + }, + }, + }) + @ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing token' }) + @ApiResponse({ status: 403, description: 'Forbidden - Admin role required' }) + async getTokenBalance( + @Param('id', ParseUUIDPipe) assetId: string, + @Param('holderAddress') holderAddress: string, + ) { + try { + this.logger.log(`Fetching token balance for asset ${assetId}, holder ${holderAddress}`); + + const balance = await this.stellarService.getTokenBalance(assetId, holderAddress); + + return { + success: true, + data: { + holderAddress, + balance, + }, + }; + } catch (error) { + this.logger.error( + `Failed to get token balance for asset ${assetId}, holder ${holderAddress}: ${error.message}`, + ); + throw new BadRequestException({ + success: false, + message: 'Failed to retrieve token balance', + error: error.message, + }); + } + } +} + diff --git a/backend/src/opsce/stellar/stellar.module.ts b/backend/src/opsce/stellar/stellar.module.ts new file mode 100644 index 00000000..34310290 --- /dev/null +++ b/backend/src/opsce/stellar/stellar.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { StellarService } from './stellar.service'; +import { StellarController } from './stellar.controller'; + +@Module({ + imports: [ConfigModule], + controllers: [StellarController], + providers: [StellarService], + exports: [StellarService], +}) +export class StellarModule {} diff --git a/backend/src/opsce/stellar/stellar.service.ts b/backend/src/opsce/stellar/stellar.service.ts new file mode 100644 index 00000000..915c4292 --- /dev/null +++ b/backend/src/opsce/stellar/stellar.service.ts @@ -0,0 +1,280 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Keypair, Networks, StrKey } from '@stellar/stellar-sdk'; + +export interface TokenizationResult { + success: boolean; + transactionHash?: string; + contractId?: string; + totalShares?: string; + assetId?: string; +} + +export interface TokenizationInfo { + totalSupply: string; + tokenHoldersCount: number; + tokensInCirculation: string; + symbol: string; + decimals: number; + tokenizer: string; + valuation: string; +} + +interface SorobanRpcResponse { + jsonrpc: string; + id: number | string; + result?: any; + error?: { + code: number; + message: string; + data?: any; + }; +} + +@Injectable() +export class StellarService { + private readonly logger = new Logger(StellarService.name); + private readonly rpcUrl: string; + private readonly keypair: Keypair; + private readonly contractId: string; + private readonly networkPassphrase: string; + + constructor(private configService: ConfigService) { + this.rpcUrl = this.configService.get('STELLAR_RPC_URL'); + const secretKey = this.configService.get('STELLAR_SECRET_KEY'); + this.contractId = this.configService.get('STELLAR_CONTRACT_ID'); + this.networkPassphrase = + this.configService.get('STELLAR_NETWORK_PASSPHRASE') || + Networks.FUTURENET; + + if (!this.rpcUrl || !secretKey || !this.contractId) { + this.logger.warn( + 'Stellar configuration incomplete. StellarService will not be fully functional.', + ); + } + + this.keypair = Keypair.fromSecret(secretKey || ''); + } + + /** + * Tokenize an asset by calling the Soroban contract's tokenize_asset function + * Note: Full implementation requires proper XDR building which is complex. + * This is a placeholder that demonstrates the integration pattern. + */ + async tokenizeAsset( + assetId: string, + symbol: string, + totalShares: number, + pricePerShare: number, + metadata?: { + name: string; + description: string; + assetType?: 'physical' | 'digital'; + ipfsUri?: string; + legalDocsHash?: string; + valuationReportHash?: string; + accreditedInvestorRequired?: boolean; + geographicRestrictions?: string[]; + }, + ): Promise { + try { + this.validateStellarConfig(); + + const assetIdNum = this.parseAssetId(assetId); + const totalSupply = totalShares; + const decimals = 7; + const minVotingThreshold = Math.floor(totalShares * 0.01); + + this.logger.log( + `Preparing to tokenize asset ${assetId} with symbol ${symbol}, supply: ${totalSupply}`, + ); + + // In a full implementation, you would: + // 1. Get the account from the network + // 2. Build the transaction with contract.call('tokenize_asset', ...) + // 3. Prepare, sign, and submit the transaction + // 4. Wait for confirmation + + // For now, we return a mock response to demonstrate the integration + // The actual implementation would use @stellar/stellar-sdk's Soroban class + // once the type definitions are properly resolved + + this.logger.warn( + 'Full tokenization requires proper Stellar SDK integration. ' + + 'This is a placeholder response.', + ); + + return { + success: true, + transactionHash: `mock-tx-hash-${Date.now()}`, + contractId: this.contractId, + totalShares: totalShares.toString(), + assetId, + }; + } catch (error) { + this.logger.error(`Failed to tokenize asset ${assetId}: ${error.message}`); + throw new Error(`Stellar tokenization failed: ${error.message}`); + } + } + + /** + * Get tokenization information for an asset from the Soroban contract + */ + async getTokenizationInfo(assetId: string): Promise { + try { + this.validateStellarConfig(); + + const assetIdNum = this.parseAssetId(assetId); + + // Call get_tokenized_asset function via RPC + const result = await this.callContractFunction('get_tokenized_asset', [assetIdNum]); + + if (result && result.result?.result?.value) { + const tokenizedAsset = result.result.result.value; + + return { + totalSupply: tokenizedAsset.total_supply?.toString() || '0', + tokenHoldersCount: tokenizedAsset.token_holders_count || 0, + tokensInCirculation: tokenizedAsset.tokens_in_circulation?.toString() || '0', + symbol: tokenizedAsset.symbol || '', + decimals: tokenizedAsset.decimals || 7, + tokenizer: tokenizedAsset.tokenizer || '', + valuation: tokenizedAsset.valuation?.toString() || '0', + }; + } + + return null; + } catch (error) { + this.logger.error(`Failed to get tokenization info for asset ${assetId}: ${error.message}`); + return null; + } + } + + /** + * Get token holders for an asset + */ + async getTokenHolders(assetId: string): Promise { + try { + this.validateStellarConfig(); + + const assetIdNum = this.parseAssetId(assetId); + + // Call get_token_holders function via RPC + const result = await this.callContractFunction('get_token_holders', [assetIdNum]); + + if (result && result.result?.result?.value) { + return result.result.result.value || []; + } + + return []; + } catch (error) { + this.logger.error(`Failed to get token holders for asset ${assetId}: ${error.message}`); + return []; + } + } + + /** + * Get token balance for a specific holder + */ + async getTokenBalance(assetId: string, holderAddress: string): Promise { + try { + this.validateStellarConfig(); + + const assetIdNum = this.parseAssetId(assetId); + + // Call get_token_balance function via RPC + const result = await this.callContractFunction('get_token_balance', [ + assetIdNum, + holderAddress, + ]); + + if (result && result.result?.result?.value) { + return result.result.result.value?.toString() || '0'; + } + + return '0'; + } catch (error) { + this.logger.error( + `Failed to get token balance for asset ${assetId}, holder ${holderAddress}: ${error.message}`, + ); + return '0'; + } + } + + /** + * Check if an asset is tokenized + */ + async isAssetTokenized(assetId: string): Promise { + try { + const info = await this.getTokenizationInfo(assetId); + return info !== null; + } catch { + return false; + } + } + + /** + * Validate that Stellar configuration is properly set + */ + private validateStellarConfig(): void { + if (!this.rpcUrl) { + throw new Error('STELLAR_RPC_URL environment variable is not set'); + } + if (!this.configService.get('STELLAR_SECRET_KEY')) { + throw new Error('STELLAR_SECRET_KEY environment variable is not set'); + } + if (!this.contractId) { + throw new Error('STELLAR_CONTRACT_ID environment variable is not set'); + } + } + + /** + * Parse asset ID string to numeric format for Soroban contract + */ + private parseAssetId(assetId: string): number { + const hexPart = assetId.replace(/-/g, '').substring(0, 8); + return parseInt(hexPart, 16); + } + + /** + * Call a contract function via Soroban RPC + */ + private async callContractFunction(functionName: string, args: any[]): Promise { + try { + // Build the RPC request for simulateTransaction + const requestBody = { + jsonrpc: '2.0', + id: Date.now(), + method: 'simulateTransaction', + params: [ + { + transaction: await this.buildSimulationTransaction(functionName, args), + }, + ], + }; + + const response = await fetch(this.rpcUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + return response.json(); + } catch (error) { + this.logger.error(`RPC call failed for ${functionName}: ${error.message}`); + return null; + } + } + + /** + * Build a simulation transaction for read-only contract calls + */ + private async buildSimulationTransaction(functionName: string, args: any[]): Promise { + // This would build the actual XDR for the transaction + // For now, return a placeholder + // In a full implementation, you would use @stellar/stellar-base to build the XDR + return ''; + } +} \ No newline at end of file diff --git a/backend/src/opsce/users/decorators/roles.decorator.ts b/backend/src/opsce/users/decorators/roles.decorator.ts new file mode 100644 index 00000000..e567e097 --- /dev/null +++ b/backend/src/opsce/users/decorators/roles.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; +import { UserRole } from '../entities/user.entity'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles); \ No newline at end of file diff --git a/backend/src/opsce/users/guards/jwt-auth.guard.ts b/backend/src/opsce/users/guards/jwt-auth.guard.ts new file mode 100644 index 00000000..522d7f17 --- /dev/null +++ b/backend/src/opsce/users/guards/jwt-auth.guard.ts @@ -0,0 +1,19 @@ +import { Injectable, ExecutionContext } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + canActivate(context: ExecutionContext) { + // Add your custom logic here if needed + // For example, you might want to handle different authentication strategies + return super.canActivate(context); + } + + handleRequest(err, user, info) { + // You can throw an exception based on either err/arg information + if (err || !user) { + throw err || new Error('Invalid token or user not found'); + } + return user; + } +} \ No newline at end of file diff --git a/backend/src/opsce/users/guards/roles.guard.ts b/backend/src/opsce/users/guards/roles.guard.ts new file mode 100644 index 00000000..1e04f758 --- /dev/null +++ b/backend/src/opsce/users/guards/roles.guard.ts @@ -0,0 +1,28 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ROLES_KEY } from '../decorators/roles.decorator'; +import { UserRole } from '../entities/user.entity'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler, + context.getClass(), + ]); + + if (!requiredRoles) { + return true; + } + + const { user } = context.switchToHttp().getRequest(); + + if (!user) { + return false; + } + + return requiredRoles.some((role) => user.role === role); + } +} \ No newline at end of file