diff --git a/.env.example b/.env.example index c861a1d..a52d75e 100644 --- a/.env.example +++ b/.env.example @@ -12,10 +12,14 @@ DB_PASSWORD=password DB_DATABASE=starshop DB_SSL=true JWT_SECRET=tu_jwt_secret +TRUSTLESS_WORK_ENV=development SOROBAN_RPC_URL=https://your-soroban-endpoint SOROBAN_SERVER_SECRET=your-secret-key +TRUSTLESS_WORK_ENV=testnet +TRUSTLESS_WORK_BASE_URL=https://api.trustlesswork.com + # Cloudinary Configuration CLOUDINARY_CLOUD_NAME=your_cloud_name CLOUDINARY_API_KEY=your_api_key diff --git a/package-lock.json b/package-lock.json index cadfaa4..2787866 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "@aws-sdk/client-s3": "^3.802.0", + "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.1.3", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.1.3", @@ -27,6 +28,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "cloudinary": "^1.41.3", + "crypto": "^1.0.1", "dotenv": "^16.4.7", "env-var": "^7.5.0", "express": "^4.21.2", @@ -44,7 +46,7 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "soroban-client": "^1.0.1", - "stellar-sdk": "^11.0.0", + "stellar-sdk": "^11.3.0", "swagger-ui-express": "^5.0.1", "typeorm": "^0.3.25", "uuid": "^11.1.0", @@ -2880,6 +2882,17 @@ "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==", "license": "MIT" }, + "node_modules/@nestjs/axios": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.1.tgz", + "integrity": "sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "axios": "^1.3.1", + "rxjs": "^7.0.0" + } + }, "node_modules/@nestjs/cli": { "version": "11.0.7", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.7.tgz", @@ -7329,6 +7342,13 @@ "node": ">= 8" } }, + "node_modules/crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz", + "integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==", + "deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in.", + "license": "ISC" + }, "node_modules/dayjs": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", diff --git a/package.json b/package.json index 6d4bafd..e029c96 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "description": "StarShop Backend API", "dependencies": { "@aws-sdk/client-s3": "^3.802.0", + "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.1.3", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.1.3", @@ -56,6 +57,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "cloudinary": "^1.41.3", + "crypto": "^1.0.1", "dotenv": "^16.4.7", "env-var": "^7.5.0", "express": "^4.21.2", @@ -73,7 +75,7 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "soroban-client": "^1.0.1", - "stellar-sdk": "^11.0.0", + "stellar-sdk": "^11.3.0", "swagger-ui-express": "^5.0.1", "typeorm": "^0.3.25", "uuid": "^11.1.0", diff --git a/src/app.module.ts b/src/app.module.ts index 0e33b0d..2a2ebb2 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -18,9 +18,10 @@ import { BuyerRequestsModule } from './modules/buyer-requests/buyer-requests.mod import { OffersModule } from './modules/offers/offers.module'; import { EscrowModule } from './modules/escrow/escrow.module'; import { SupabaseModule } from './modules/supabase/supabase.module'; -import { EscrowModule } from './modules/escrow/escrow.module'; import { AppCacheModule } from './cache/cache.module'; import { StoresModule } from './modules/stores/stores.module'; +import { TrustlessWorkModule } from './modules/trustlessWork/trustless-work.module'; +import { EscrowsModule } from './modules/escrows/escrows.module'; // Entities import { User } from './modules/users/entities/user.entity'; @@ -45,13 +46,15 @@ import { Milestone } from './modules/escrow/entities/milestone.entity'; import { Escrow } from './modules/escrow/entities/escrow.entity'; import { EscrowFundingTx } from './modules/escrow/entities/escrow-funding-tx.entity'; import { Store } from './modules/stores/entities/store.entity'; -import { Escrow } from './modules/escrows/entities/escrow.entity'; -import { Milestone } from './modules/escrows/entities/milestone.entity'; -import { EscrowsModule } from './modules/escrows/escrows.module'; +import { Escrow as EscrowV2 } from './modules/escrows/entities/escrow.entity'; +import { Milestone as MilestoneV2 } from './modules/escrows/entities/milestone.entity'; @Module({ imports: [ - ConfigModule.forRoot({ isGlobal: true }), + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: ['.env'], + }), ScheduleModule.forRoot(), AppCacheModule, TypeOrmModule.forRoot({ @@ -78,12 +81,11 @@ import { EscrowsModule } from './modules/escrows/escrows.module'; OfferAttachment, EscrowAccount, Milestone, - - Escrow, - EscrowFundingTx, + Escrow, + EscrowFundingTx, Store, - Escrow, - Milestone, + EscrowV2, + MilestoneV2, ], synchronize: false, logging: process.env.NODE_ENV === 'development', @@ -103,9 +105,9 @@ import { EscrowsModule } from './modules/escrows/escrows.module'; OffersModule, EscrowModule, SupabaseModule, - EscrowModule, + TrustlessWorkModule, StoresModule, EscrowsModule, ], }) -export class AppModule { } +export class AppModule { } \ No newline at end of file diff --git a/src/modules/trustlessWork/controllers/trustless-work.controller.ts b/src/modules/trustlessWork/controllers/trustless-work.controller.ts new file mode 100644 index 0000000..4f65c0b --- /dev/null +++ b/src/modules/trustlessWork/controllers/trustless-work.controller.ts @@ -0,0 +1,272 @@ +import { + Controller, + Post, + Body, + HttpCode, + HttpStatus, + Get, + Param, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger'; +import { TrustlessWorkService } from '../services/trustless-work.service'; +import { + InitializeEscrowDto, + SendSignedTransactionDto, + GetEscrowsBySignerDto, + DeploySingleReleaseEscrowDto, + DeployMultiReleaseEscrowDto, + FundEscrowDto, + GetEscrowDto, + ApproveMilestoneDto, + ReleaseFundsDto, + ReleaseMilestoneFundsDto, + DisputeEscrowDto, + ResolveDisputeDto, + UpdateEscrowDto, + SetTrustlineDto, + SendTransactionDto, + GetEscrowsByRoleDto, + EscrowType, +} from '../dtos/trustless-work.dto'; + +@ApiTags('Trustless Work') +@Controller('trustless-work') +export class TrustlessWorkController { + constructor(private readonly trustlessWorkService: TrustlessWorkService) {} + + // ===================== + // MAIN ENDPOINTS PARA ISSUE #3 + // ===================== + + @Post('initialize-escrow') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'Initialize a new escrow contract (Issue #3)', + description: 'Creates a new escrow contract and returns unsigned XDR for signing', + }) + @ApiResponse({ + status: 201, + description: 'Escrow initialized successfully, returns contractId and unsigned XDR', + schema: { + example: { + success: true, + contract_id: 'CA123ABC...', + unsigned_xdr: 'AAAAAG...', + message: 'Escrow initialized successfully', + }, + }, + }) + @ApiResponse({ + status: 400, + description: 'Bad request - validation failed or seller not registered', + }) + async initializeEscrow(@Body() initDto: InitializeEscrowDto) { + return this.trustlessWorkService.initializeEscrow(initDto); + } + + @Post('send-transaction') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Send signed transaction to Stellar network', + description: 'Submits a signed XDR transaction to complete escrow creation', + }) + @ApiResponse({ + status: 200, + description: 'Transaction sent successfully', + schema: { + example: { + success: true, + transaction_hash: 'abc123...', + message: 'Transaction sent successfully', + }, + }, + }) + @ApiResponse({ + status: 400, + description: 'Missing signature or invalid XDR', + }) + async sendSignedTransaction(@Body() transactionDto: SendSignedTransactionDto) { + return this.trustlessWorkService.sendSignedTransaction(transactionDto); + } + + @Post('seller/:sellerKey/escrows') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get escrows for a specific seller', + description: 'Retrieves all escrows where the seller is a participant', + }) + async getSellerEscrows( + @Param('sellerKey') sellerKey: string, + @Body() queryDto: Omit, + ) { + return this.trustlessWorkService.getEscrowsBySigner({ + ...queryDto, + signer: sellerKey, + }); + } + + // ===================== + // HEALTH CHECK + // ===================== + + @Get('health') + @ApiOperation({ summary: 'Check Trustless Work API health' }) + async healthCheck() { + const isHealthy = await this.trustlessWorkService.healthCheck(); + const config = this.trustlessWorkService.getApiConfiguration(); + + return { + status: isHealthy ? 'healthy' : 'unhealthy', + timestamp: new Date().toISOString(), + configuration: config, + }; + } + + // Deployment Endpoints + @Post('deploy/single-release') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Deploy a single release escrow contract' }) + @ApiResponse({ status: 201, description: 'Escrow deployed successfully' }) + async deploySingleReleaseEscrow(@Body() deployDto: DeploySingleReleaseEscrowDto) { + return this.trustlessWorkService.deploySingleReleaseEscrow(deployDto); + } + + @Post('deploy/multi-release') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Deploy a multi release escrow contract' }) + @ApiResponse({ status: 201, description: 'Escrow deployed successfully' }) + async deployMultiReleaseEscrow(@Body() deployDto: DeployMultiReleaseEscrowDto) { + return this.trustlessWorkService.deployMultiReleaseEscrow(deployDto); + } + + // Funding Endpoints + @Post('escrow/:type/fund') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Fund an escrow contract' }) + @ApiParam({ name: 'type', enum: EscrowType, description: 'Escrow type' }) + @ApiResponse({ status: 200, description: 'Escrow funded successfully' }) + async fundEscrow( + @Param('type') escrowType: EscrowType, + @Body() fundDto: FundEscrowDto, + ) { + return this.trustlessWorkService.fundEscrow(escrowType, fundDto); + } + + // Query Endpoints + @Post('escrow/:type/details') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Get escrow details' }) + @ApiParam({ name: 'type', enum: EscrowType, description: 'Escrow type' }) + @ApiResponse({ status: 200, description: 'Escrow details retrieved successfully' }) + async getEscrowDetails( + @Param('type') escrowType: EscrowType, + @Body() getDto: GetEscrowDto, + ) { + return this.trustlessWorkService.getEscrowDetails(escrowType, getDto); + } + + // Milestone Management + @Post('escrow/:type/approve-milestone') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Approve a milestone' }) + @ApiParam({ name: 'type', enum: EscrowType, description: 'Escrow type' }) + @ApiResponse({ status: 200, description: 'Milestone approved successfully' }) + async approveMilestone( + @Param('type') escrowType: EscrowType, + @Body() approveDto: ApproveMilestoneDto, + ) { + return this.trustlessWorkService.approveMilestone(escrowType, approveDto); + } + + // Fund Release + @Post('escrow/:type/release-funds') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Release escrow funds' }) + @ApiParam({ name: 'type', enum: EscrowType, description: 'Escrow type' }) + @ApiResponse({ status: 200, description: 'Funds released successfully' }) + async releaseFunds( + @Param('type') escrowType: EscrowType, + @Body() releaseDto: ReleaseFundsDto, + ) { + return this.trustlessWorkService.releaseFunds(escrowType, releaseDto); + } + + @Post('escrow/multi-release/release-milestone-funds') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Release milestone funds for multi-release escrow' }) + @ApiResponse({ status: 200, description: 'Milestone funds released successfully' }) + async releaseMilestoneFunds(@Body() releaseDto: ReleaseMilestoneFundsDto) { + return this.trustlessWorkService.releaseMilestoneFunds(releaseDto); + } + + // Dispute Management + @Post('escrow/:type/dispute') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Create a dispute for an escrow' }) + @ApiParam({ name: 'type', enum: EscrowType, description: 'Escrow type' }) + @ApiResponse({ status: 200, description: 'Dispute created successfully' }) + async disputeEscrow( + @Param('type') escrowType: EscrowType, + @Body() disputeDto: DisputeEscrowDto, + ) { + return this.trustlessWorkService.disputeEscrow(escrowType, disputeDto); + } + + @Post('escrow/:type/resolve-dispute') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Resolve a dispute' }) + @ApiParam({ name: 'type', enum: EscrowType, description: 'Escrow type' }) + @ApiResponse({ status: 200, description: 'Dispute resolved successfully' }) + async resolveDispute( + @Param('type') escrowType: EscrowType, + @Body() resolveDto: ResolveDisputeDto, + ) { + return this.trustlessWorkService.resolveDispute(escrowType, resolveDto); + } + + // Update Operations + @Post('escrow/:type/update') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Update escrow details' }) + @ApiParam({ name: 'type', enum: EscrowType, description: 'Escrow type' }) + @ApiResponse({ status: 200, description: 'Escrow updated successfully' }) + async updateEscrow( + @Param('type') escrowType: EscrowType, + @Body() updateDto: UpdateEscrowDto, + ) { + return this.trustlessWorkService.updateEscrow(escrowType, updateDto); + } + + // Helper Endpoints + @Post('helper/set-trustline') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Set trustline for an account' }) + @ApiResponse({ status: 200, description: 'Trustline set successfully' }) + async setTrustline(@Body() trustlineDto: SetTrustlineDto) { + return this.trustlessWorkService.setTrustline(trustlineDto); + } + + @Post('helper/send-transaction') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Send a signed transaction to Stellar' }) + @ApiResponse({ status: 200, description: 'Transaction sent successfully' }) + async sendTransaction(@Body() transactionDto: SendTransactionDto) { + return this.trustlessWorkService.sendTransaction(transactionDto); + } + + @Post('helper/escrows-by-signer') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Get escrows by signer' }) + @ApiResponse({ status: 200, description: 'Escrows retrieved successfully' }) + async getEscrowsBySigner(@Body() queryDto: GetEscrowsBySignerDto) { + return this.trustlessWorkService.getEscrowsBySigner(queryDto); + } + + @Post('helper/escrows-by-role') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Get escrows by role' }) + @ApiResponse({ status: 200, description: 'Escrows retrieved successfully' }) + async getEscrowsByRole(@Body() queryDto: GetEscrowsByRoleDto) { + return this.trustlessWorkService.getEscrowsByRole(queryDto); + } +} \ No newline at end of file diff --git a/src/modules/trustlessWork/dtos/trustless-work.dto.ts b/src/modules/trustlessWork/dtos/trustless-work.dto.ts new file mode 100644 index 0000000..00e39c6 --- /dev/null +++ b/src/modules/trustlessWork/dtos/trustless-work.dto.ts @@ -0,0 +1,410 @@ +import { IsString, IsEnum, IsOptional, IsNumber, IsArray, ValidateNested, IsBoolean, Matches, IsNotEmpty } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export enum NetworkType { + TESTNET = 'testnet', + MAINNET = 'mainnet', +} + +export enum EscrowType { + SINGLE_RELEASE = 'single-release', + MULTI_RELEASE = 'multi-release', +} + +export class MilestoneDto { + @ApiProperty({ description: 'Milestone title' }) + @IsString() + @IsNotEmpty() + title: string; + + @ApiProperty({ description: 'Milestone description' }) + @IsString() + @IsNotEmpty() + description: string; + + @ApiProperty({ description: 'Milestone amount' }) + @IsString() + @IsNotEmpty() + amount: string; + + @ApiPropertyOptional({ description: 'Milestone due date' }) + @IsOptional() + @IsString() + due_date?: string; +} + +// DTO específico para la issue - Initialize Escrow +export class InitializeEscrowDto { + @ApiProperty({ enum: EscrowType, description: 'Type of escrow to deploy' }) + @IsEnum(EscrowType) + type: EscrowType; + + @ApiProperty({ description: 'Seller (Service provider) Stellar public key' }) + @IsString() + @IsNotEmpty() + @Matches(/^G[A-Z0-9]{55}$/, { message: 'Invalid Stellar public key format' }) + seller_key: string; + + @ApiProperty({ description: 'Approver Stellar public key' }) + @IsString() + @IsNotEmpty() + @Matches(/^G[A-Z0-9]{55}$/, { message: 'Invalid Stellar public key format' }) + approver: string; + + @ApiProperty({ description: 'Receiver Stellar public key' }) + @IsString() + @IsNotEmpty() + @Matches(/^G[A-Z0-9]{55}$/, { message: 'Invalid Stellar public key format' }) + receiver: string; + + @ApiProperty({ description: 'Dispute resolver Stellar public key' }) + @IsString() + @IsNotEmpty() + @Matches(/^G[A-Z0-9]{55}$/, { message: 'Invalid Stellar public key format' }) + dispute_resolver: string; + + @ApiProperty({ description: 'Total escrow amount' }) + @IsString() + @IsNotEmpty() + total_amount?: string; // Solo para single-release + + @ApiProperty({ type: [MilestoneDto], description: 'List of milestones (for multi-release)' }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => MilestoneDto) + milestones?: MilestoneDto[]; // Solo para multi-release + + @ApiProperty({ description: 'Asset code (e.g., USDC, XLM)' }) + @IsString() + @IsNotEmpty() + asset: string; + + @ApiPropertyOptional({ description: 'Asset issuer (for custom assets)' }) + @IsOptional() + @IsString() + asset_issuer?: string; + + @ApiProperty({ description: 'Escrow title' }) + @IsString() + @IsNotEmpty() + title: string; + + @ApiProperty({ description: 'Escrow description' }) + @IsString() + @IsNotEmpty() + description: string; + + @ApiPropertyOptional({ description: 'Platform fee percentage' }) + @IsOptional() + @IsString() + platform_fee?: string; + + @ApiPropertyOptional({ description: 'Platform fee receiver' }) + @IsOptional() + @IsString() + platform_fee_receiver?: string; +} + +export class SendSignedTransactionDto { + @ApiProperty({ enum: NetworkType, description: 'Stellar network' }) + @IsEnum(NetworkType) + network: NetworkType; + + @ApiProperty({ description: 'Signed XDR transaction' }) + @IsString() + @IsNotEmpty() + signed_xdr: string; + + @ApiPropertyOptional({ description: 'Contract ID for tracking' }) + @IsOptional() + @IsString() + contract_id?: string; +} + + + +export class DeploySingleReleaseEscrowDto { + @ApiProperty({ description: 'Service provider Stellar public key' }) + @IsString() + service_provider: string; + + @ApiProperty({ description: 'Approver Stellar public key' }) + @IsString() + approver: string; + + @ApiProperty({ description: 'Receiver Stellar public key' }) + @IsString() + receiver: string; + + @ApiProperty({ description: 'Dispute resolver Stellar public key' }) + @IsString() + dispute_resolver: string; + + @ApiProperty({ description: 'Total escrow amount' }) + @IsString() + total_amount: string; + + @ApiProperty({ description: 'Asset code (e.g., USDC, XLM)' }) + @IsString() + asset: string; + + @ApiPropertyOptional({ description: 'Asset issuer (for custom assets)' }) + @IsOptional() + @IsString() + asset_issuer?: string; + + @ApiProperty({ description: 'Escrow title' }) + @IsString() + title: string; + + @ApiProperty({ description: 'Escrow description' }) + @IsString() + description: string; + + @ApiPropertyOptional({ description: 'Platform fee percentage' }) + @IsOptional() + @IsString() + platform_fee?: string; + + @ApiPropertyOptional({ description: 'Platform fee receiver' }) + @IsOptional() + @IsString() + platform_fee_receiver?: string; +} + +export class DeployMultiReleaseEscrowDto { + @ApiProperty({ description: 'Service provider Stellar public key' }) + @IsString() + service_provider: string; + + @ApiProperty({ description: 'Approver Stellar public key' }) + @IsString() + approver: string; + + @ApiProperty({ description: 'Receiver Stellar public key' }) + @IsString() + receiver: string; + + @ApiProperty({ description: 'Dispute resolver Stellar public key' }) + @IsString() + dispute_resolver: string; + + @ApiProperty({ type: [MilestoneDto], description: 'List of milestones' }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => MilestoneDto) + milestones: MilestoneDto[]; + + @ApiProperty({ description: 'Asset code (e.g., USDC, XLM)' }) + @IsString() + asset: string; + + @ApiPropertyOptional({ description: 'Asset issuer (for custom assets)' }) + @IsOptional() + @IsString() + asset_issuer?: string; + + @ApiProperty({ description: 'Escrow title' }) + @IsString() + title: string; + + @ApiProperty({ description: 'Escrow description' }) + @IsString() + description: string; + + @ApiPropertyOptional({ description: 'Platform fee percentage' }) + @IsOptional() + @IsString() + platform_fee?: string; + + @ApiPropertyOptional({ description: 'Platform fee receiver' }) + @IsOptional() + @IsString() + platform_fee_receiver?: string; +} + +export class FundEscrowDto { + @ApiProperty({ description: 'Contract ID of the escrow' }) + @IsString() + contract_id: string; + + @ApiProperty({ description: 'Funder Stellar public key' }) + @IsString() + funder_key: string; + + @ApiProperty({ description: 'Amount to fund' }) + @IsString() + amount: string; +} + +export class GetEscrowDto { + @ApiProperty({ enum: NetworkType, description: 'Stellar network' }) + @IsEnum(NetworkType) + network: NetworkType; + + @ApiProperty({ description: 'Contract ID of the escrow' }) + @IsString() + contract_id: string; +} + +export class ApproveMilestoneDto { + @ApiProperty({ description: 'Contract ID of the escrow' }) + @IsString() + contract_id: string; + + @ApiProperty({ description: 'Approver Stellar public key' }) + @IsString() + approver_key: string; + + @ApiProperty({ description: 'Milestone index to approve' }) + @IsNumber() + milestone_index: number; +} + +export class ReleaseFundsDto { + @ApiProperty({ description: 'Contract ID of the escrow' }) + @IsString() + contract_id: string; + + @ApiProperty({ description: 'Releaser Stellar public key' }) + @IsString() + releaser_key: string; +} + +export class ReleaseMilestoneFundsDto { + @ApiProperty({ description: 'Contract ID of the escrow' }) + @IsString() + contract_id: string; + + @ApiProperty({ description: 'Releaser Stellar public key' }) + @IsString() + releaser_key: string; + + @ApiProperty({ description: 'Milestone index to release' }) + @IsNumber() + milestone_index: number; +} + +export class DisputeEscrowDto { + @ApiProperty({ description: 'Contract ID of the escrow' }) + @IsString() + contract_id: string; + + @ApiProperty({ description: 'Disputer Stellar public key' }) + @IsString() + disputer_key: string; + + @ApiProperty({ description: 'Dispute reason' }) + @IsString() + reason: string; +} + +export class ResolveDisputeDto { + @ApiProperty({ description: 'Contract ID of the escrow' }) + @IsString() + contract_id: string; + + @ApiProperty({ description: 'Dispute resolver Stellar public key' }) + @IsString() + resolver_key: string; + + @ApiProperty({ description: 'Resolution decision' }) + @IsBoolean() + approve_release: boolean; + + @ApiPropertyOptional({ description: 'Resolution notes' }) + @IsOptional() + @IsString() + resolution_notes?: string; +} + +export class UpdateEscrowDto { + @ApiProperty({ description: 'Contract ID of the escrow' }) + @IsString() + contract_id: string; + + @ApiProperty({ description: 'Updater Stellar public key' }) + @IsString() + updater_key: string; + + @ApiPropertyOptional({ description: 'New title' }) + @IsOptional() + @IsString() + title?: string; + + @ApiPropertyOptional({ description: 'New description' }) + @IsOptional() + @IsString() + description?: string; +} + +export class SetTrustlineDto { + @ApiProperty({ description: 'Account Stellar public key' }) + @IsString() + account_key: string; + + @ApiProperty({ description: 'Asset code' }) + @IsString() + asset: string; + + @ApiProperty({ description: 'Asset issuer' }) + @IsString() + asset_issuer: string; + + @ApiPropertyOptional({ description: 'Trust limit' }) + @IsOptional() + @IsString() + limit?: string; +} + +export class SendTransactionDto { + @ApiProperty({ description: 'Signed XDR transaction' }) + @IsString() + signed_xdr: string; +} + +export class GetEscrowsBySignerDto { + @ApiProperty({ enum: NetworkType, description: 'Stellar network' }) + @IsEnum(NetworkType) + network: NetworkType; + + @ApiProperty({ description: 'Signer Stellar public key' }) + @IsString() + signer: string; + + @ApiPropertyOptional({ description: 'Page number for pagination' }) + @IsOptional() + @IsNumber() + page?: number; + + @ApiPropertyOptional({ description: 'Items per page' }) + @IsOptional() + @IsNumber() + limit?: number; +} + +export class GetEscrowsByRoleDto { + @ApiProperty({ enum: NetworkType, description: 'Stellar network' }) + @IsEnum(NetworkType) + network: NetworkType; + + @ApiProperty({ description: 'User Stellar public key' }) + @IsString() + user_key: string; + + @ApiProperty({ description: 'Role to filter by' }) + @IsString() + role: string; + + @ApiPropertyOptional({ description: 'Page number for pagination' }) + @IsOptional() + @IsNumber() + page?: number; + + @ApiPropertyOptional({ description: 'Items per page' }) + @IsOptional() + @IsNumber() + limit?: number; +} \ No newline at end of file diff --git a/src/modules/trustlessWork/services/trustless-work.service.ts b/src/modules/trustlessWork/services/trustless-work.service.ts new file mode 100644 index 0000000..88e02fa --- /dev/null +++ b/src/modules/trustlessWork/services/trustless-work.service.ts @@ -0,0 +1,731 @@ +import { Injectable, HttpException, HttpStatus, Logger, BadRequestException } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { firstValueFrom } from 'rxjs'; +import { + InitializeEscrowDto, + SendSignedTransactionDto, + EscrowType, + DeploySingleReleaseEscrowDto, + DeployMultiReleaseEscrowDto, + FundEscrowDto, + GetEscrowDto, + ApproveMilestoneDto, + ReleaseFundsDto, + ReleaseMilestoneFundsDto, + DisputeEscrowDto, + ResolveDisputeDto, + UpdateEscrowDto, + SetTrustlineDto, + SendTransactionDto, + GetEscrowsBySignerDto, + GetEscrowsByRoleDto, +} from '../dtos/trustless-work.dto'; + +@Injectable() +export class TrustlessWorkService { + private readonly logger = new Logger(TrustlessWorkService.name); + private readonly baseUrl: string; + private readonly apiHeaders: Record; + + constructor( + private readonly httpService: HttpService, + private readonly configService: ConfigService, + ) { + this.baseUrl = this.configService.get('TRUSTLESS_WORK_API_URL', 'https://api.trustlesswork.com'); + + // Configurar headers por defecto + this.apiHeaders = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + + // Si tienes API key, agrégala aquí + const apiKey = this.configService.get('TRUSTLESS_WORK_API_KEY'); + if (apiKey) { + this.apiHeaders['Authorization'] = `Bearer ${apiKey}`; + } + + this.logger.log(`Trustless Work Service initialized with base URL: ${this.baseUrl}`); + } + + // ===================== + // MAIN INITIALIZE ESCROW METHOD (Para la Issue #3) + // ===================== + + async initializeEscrow(initDto: InitializeEscrowDto): Promise<{ + success: boolean; + contract_id?: string; + unsigned_xdr?: string; + message?: string; + }> { + this.logger.log('Initializing escrow', { + type: initDto.type, + seller: initDto.seller_key, + network: this.configService.get('STELLAR_NETWORK') || 'testnet', + }); + + // 1. Validar que el seller esté registrado on-chain + await this.validateSellerRegistration(initDto.seller_key, this.configService.get('STELLAR_NETWORK') || 'testnet'); + + // 2. Validar campos según el tipo de escrow + this.validateEscrowPayload(initDto); + + try { + let response; + + if (initDto.type === EscrowType.SINGLE_RELEASE) { + response = await this.deploySingleReleaseEscrowInternal({ + network: this.configService.get('STELLAR_NETWORK') || 'testnet', + service_provider: initDto.seller_key, + approver: initDto.approver, + receiver: initDto.receiver, + dispute_resolver: initDto.dispute_resolver, + total_amount: initDto.total_amount, + asset: initDto.asset, + asset_issuer: initDto.asset_issuer, + title: initDto.title, + description: initDto.description, + platform_fee: initDto.platform_fee, + platform_fee_receiver: initDto.platform_fee_receiver, + }); + } else { + response = await this.deployMultiReleaseEscrowInternal({ + network: this.configService.get('STELLAR_NETWORK') || 'testnet', + service_provider: initDto.seller_key, + approver: initDto.approver, + receiver: initDto.receiver, + dispute_resolver: initDto.dispute_resolver, + milestones: initDto.milestones, + asset: initDto.asset, + asset_issuer: initDto.asset_issuer, + title: initDto.title, + description: initDto.description, + platform_fee: initDto.platform_fee, + platform_fee_receiver: initDto.platform_fee_receiver, + }); + } + + this.logger.log('Escrow initialized successfully', { + contractId: response.contract_id, + type: initDto.type, + }); + + return { + success: true, + contract_id: response.contract_id, + unsigned_xdr: response.unsigned_xdr, + message: 'Escrow initialized successfully', + }; + + } catch (error) { + this.logger.error('Failed to initialize escrow', error); + throw error; + } + } + + async sendSignedTransaction(transactionDto: SendSignedTransactionDto): Promise<{ + success: boolean; + transaction_hash?: string; + message?: string; + }> { + this.logger.log('Sending signed transaction', { + network: transactionDto.network, + contractId: transactionDto.contract_id, + }); + + // Validar que el XDR no esté vacío + if (!transactionDto.signed_xdr || transactionDto.signed_xdr.trim() === '') { + throw new BadRequestException('Missing signature: signed_xdr is required and cannot be empty'); + } + + try { + const response = await firstValueFrom( + this.httpService.post(`${this.baseUrl}/helper/send-transaction`, { + network: transactionDto.network, + signed_xdr: transactionDto.signed_xdr, + }, { + headers: this.apiHeaders, + }) + ); + + this.logger.log('Transaction sent successfully', { + transactionHash: response.data.transaction_hash, + contractId: transactionDto.contract_id, + }); + + return { + success: true, + transaction_hash: response.data.transaction_hash, + message: 'Transaction sent successfully', + }; + + } catch (error) { + this.logger.error('Failed to send transaction', error); + this.handleApiError(error, 'Send Signed Transaction'); + } + } + + // ===================== + // DEPLOYMENT METHODS + // ===================== + + async deploySingleReleaseEscrow(deployDto: DeploySingleReleaseEscrowDto): Promise { + this.logger.log('Deploying single release escrow'); + try { + const response = await firstValueFrom( + this.httpService.post(`${this.baseUrl}/deployer/single-release`, deployDto, { + headers: this.apiHeaders, + }) + ); + + this.logger.log('Single release escrow deployed successfully', { + contractId: response.data.contract_id, + }); + + return response.data; + } catch (error) { + this.logger.error('Failed to deploy single release escrow', error); + this.handleApiError(error, 'Deploy Single Release Escrow'); + } + } + + async deployMultiReleaseEscrow(deployDto: DeployMultiReleaseEscrowDto): Promise { + this.logger.log('Deploying multi release escrow'); + try { + const response = await firstValueFrom( + this.httpService.post(`${this.baseUrl}/deployer/multi-release`, deployDto, { + headers: this.apiHeaders, + }) + ); + + this.logger.log('Multi release escrow deployed successfully', { + contractId: response.data.contract_id, + }); + + return response.data; + } catch (error) { + this.logger.error('Failed to deploy multi release escrow', error); + this.handleApiError(error, 'Deploy Multi Release Escrow'); + } + } + + // ===================== + // FUNDING METHODS + // ===================== + + async fundEscrow(escrowType: EscrowType, fundDto: FundEscrowDto): Promise { + this.logger.log(`Funding ${escrowType} escrow`, { contractId: fundDto.contract_id }); + try { + const response = await firstValueFrom( + this.httpService.post(`${this.baseUrl}/escrow/${escrowType}/fund-escrow`, fundDto, { + headers: this.apiHeaders, + }) + ); + + this.logger.log('Escrow funded successfully', { + contractId: fundDto.contract_id, + amount: fundDto.amount, + }); + + return response.data; + } catch (error) { + this.logger.error('Failed to fund escrow', error); + this.handleApiError(error, 'Fund Escrow'); + } + } + + // ===================== + // QUERY METHODS + // ===================== + + async getEscrowDetails(escrowType: EscrowType, getDto: GetEscrowDto): Promise { + this.logger.log(`Getting ${escrowType} escrow details`, { contractId: getDto.contract_id }); + try { + const response = await firstValueFrom( + this.httpService.post(`${this.baseUrl}/escrow/${escrowType}/get-escrow`, getDto, { + headers: this.apiHeaders, + }) + ); + + this.logger.log('Escrow details retrieved successfully', { + contractId: getDto.contract_id, + }); + + return response.data; + } catch (error) { + this.logger.error('Failed to get escrow details', error); + this.handleApiError(error, 'Get Escrow Details'); + } + } + + async getMultipleEscrowBalance(contractIds: string[], network: string): Promise { + this.logger.log('Getting multiple escrow balances'); + try { + const response = await firstValueFrom( + this.httpService.post(`${this.baseUrl}/helper/get-multiple-escrow-balance`, { + contract_ids: contractIds, + network, + }, { + headers: this.apiHeaders, + }) + ); + + this.logger.log('Multiple escrow balances retrieved successfully'); + return response.data; + } catch (error) { + this.logger.error('Failed to get multiple escrow balances', error); + this.handleApiError(error, 'Get Multiple Escrow Balance'); + } + } + + // ===================== + // MILESTONE METHODS + // ===================== + + async approveMilestone(escrowType: EscrowType, approveDto: ApproveMilestoneDto): Promise { + this.logger.log(`Approving milestone for ${escrowType} escrow`, { + contractId: approveDto.contract_id, + milestoneIndex: approveDto.milestone_index, + }); + + try { + const response = await firstValueFrom( + this.httpService.post(`${this.baseUrl}/escrow/${escrowType}/approve-milestone`, approveDto, { + headers: this.apiHeaders, + }) + ); + + this.logger.log('Milestone approved successfully', { + contractId: approveDto.contract_id, + milestoneIndex: approveDto.milestone_index, + }); + + return response.data; + } catch (error) { + this.logger.error('Failed to approve milestone', error); + this.handleApiError(error, 'Approve Milestone'); + } + } + + async changeMilestoneStatus(escrowType: EscrowType, statusDto: any): Promise { + this.logger.log(`Changing milestone status for ${escrowType} escrow`); + try { + const response = await firstValueFrom( + this.httpService.post(`${this.baseUrl}/escrow/${escrowType}/change-milestone-status`, statusDto, { + headers: this.apiHeaders, + }) + ); + + this.logger.log('Milestone status changed successfully'); + return response.data; + } catch (error) { + this.logger.error('Failed to change milestone status', error); + this.handleApiError(error, 'Change Milestone Status'); + } + } + + // ===================== + // FUND RELEASE METHODS + // ===================== + + async releaseFunds(escrowType: EscrowType, releaseDto: ReleaseFundsDto): Promise { + this.logger.log(`Releasing funds for ${escrowType} escrow`, { + contractId: releaseDto.contract_id, + }); + + try { + const endpoint = escrowType === EscrowType.SINGLE_RELEASE ? 'release-funds' : 'release-funds'; + const response = await firstValueFrom( + this.httpService.post(`${this.baseUrl}/escrow/${escrowType}/${endpoint}`, releaseDto, { + headers: this.apiHeaders, + }) + ); + + this.logger.log('Funds released successfully', { + contractId: releaseDto.contract_id, + }); + + return response.data; + } catch (error) { + this.logger.error('Failed to release funds', error); + this.handleApiError(error, 'Release Funds'); + } + } + + async releaseMilestoneFunds(releaseDto: ReleaseMilestoneFundsDto): Promise { + this.logger.log('Releasing milestone funds', { + contractId: releaseDto.contract_id, + milestoneIndex: releaseDto.milestone_index, + }); + + try { + const response = await firstValueFrom( + this.httpService.post(`${this.baseUrl}/escrow/multi-release/release-milestone-funds`, releaseDto, { + headers: this.apiHeaders, + }) + ); + + this.logger.log('Milestone funds released successfully', { + contractId: releaseDto.contract_id, + milestoneIndex: releaseDto.milestone_index, + }); + + return response.data; + } catch (error) { + this.logger.error('Failed to release milestone funds', error); + this.handleApiError(error, 'Release Milestone Funds'); + } + } + + // ===================== + // DISPUTE METHODS + // ===================== + + async disputeEscrow(escrowType: EscrowType, disputeDto: DisputeEscrowDto): Promise { + this.logger.log(`Creating dispute for ${escrowType} escrow`, { + contractId: disputeDto.contract_id, + reason: disputeDto.reason, + }); + + try { + const response = await firstValueFrom( + this.httpService.post(`${this.baseUrl}/escrow/${escrowType}/dispute-escrow`, disputeDto, { + headers: this.apiHeaders, + }) + ); + + this.logger.log('Dispute created successfully', { + contractId: disputeDto.contract_id, + }); + + return response.data; + } catch (error) { + this.logger.error('Failed to create dispute', error); + this.handleApiError(error, 'Dispute Escrow'); + } + } + + async resolveDispute(escrowType: EscrowType, resolveDto: ResolveDisputeDto): Promise { + this.logger.log(`Resolving dispute for ${escrowType} escrow`, { + contractId: resolveDto.contract_id, + approveRelease: resolveDto.approve_release, + }); + + try { + const response = await firstValueFrom( + this.httpService.post(`${this.baseUrl}/escrow/${escrowType}/resolve-dispute`, resolveDto, { + headers: this.apiHeaders, + }) + ); + + this.logger.log('Dispute resolved successfully', { + contractId: resolveDto.contract_id, + }); + + return response.data; + } catch (error) { + this.logger.error('Failed to resolve dispute', error); + this.handleApiError(error, 'Resolve Dispute'); + } + } + + async disputeMilestone(disputeDto: any): Promise { + this.logger.log('Creating milestone dispute'); + try { + const response = await firstValueFrom( + this.httpService.post(`${this.baseUrl}/escrow/multi-release/dispute-milestone`, disputeDto, { + headers: this.apiHeaders, + }) + ); + + this.logger.log('Milestone dispute created successfully'); + return response.data; + } catch (error) { + this.logger.error('Failed to create milestone dispute', error); + this.handleApiError(error, 'Dispute Milestone'); + } + } + + async resolveMilestoneDispute(resolveDto: any): Promise { + this.logger.log('Resolving milestone dispute'); + try { + const response = await firstValueFrom( + this.httpService.post(`${this.baseUrl}/escrow/multi-release/resolve-milestone-dispute`, resolveDto, { + headers: this.apiHeaders, + }) + ); + + this.logger.log('Milestone dispute resolved successfully'); + return response.data; + } catch (error) { + this.logger.error('Failed to resolve milestone dispute', error); + this.handleApiError(error, 'Resolve Milestone Dispute'); + } + } + + // ===================== + // UPDATE METHODS + // ===================== + + async updateEscrow(escrowType: EscrowType, updateDto: UpdateEscrowDto): Promise { + this.logger.log(`Updating ${escrowType} escrow`, { + contractId: updateDto.contract_id, + }); + + try { + const response = await firstValueFrom( + this.httpService.post(`${this.baseUrl}/escrow/${escrowType}/update-escrow`, updateDto, { + headers: this.apiHeaders, + }) + ); + + this.logger.log('Escrow updated successfully', { + contractId: updateDto.contract_id, + }); + + return response.data; + } catch (error) { + this.logger.error('Failed to update escrow', error); + this.handleApiError(error, 'Update Escrow'); + } + } + + // ===================== + // HELPER METHODS + // ===================== + + async setTrustline(trustlineDto: SetTrustlineDto): Promise { + this.logger.log('Setting trustline', { + account: trustlineDto.account_key, + asset: trustlineDto.asset, + }); + + try { + const response = await firstValueFrom( + this.httpService.post(`${this.baseUrl}/helper/set-trustline`, trustlineDto, { + headers: this.apiHeaders, + }) + ); + + this.logger.log('Trustline set successfully'); + return response.data; + } catch (error) { + this.logger.error('Failed to set trustline', error); + this.handleApiError(error, 'Set Trustline'); + } + } + + async sendTransaction(transactionDto: SendTransactionDto): Promise { + this.logger.log('Sending transaction to Stellar network', { + network: this.configService.get('STELLAR_NETWORK') || 'testnet', + }); + + try { + const response = await firstValueFrom( + this.httpService.post(`${this.baseUrl}/helper/send-transaction`, transactionDto, { + headers: this.apiHeaders, + }) + ); + + this.logger.log('Transaction sent successfully'); + return response.data; + } catch (error) { + this.logger.error('Failed to send transaction', error); + this.handleApiError(error, 'Send Transaction'); + } + } + + async getEscrowsBySigner(queryDto: GetEscrowsBySignerDto): Promise { + this.logger.log('Getting escrows by signer', { + signer: queryDto.signer, + network: queryDto.network, + }); + + try { + const response = await firstValueFrom( + this.httpService.post(`${this.baseUrl}/helper/get-escrows-by-signer`, queryDto, { + headers: this.apiHeaders, + }) + ); + + this.logger.log('Escrows by signer retrieved successfully'); + return response.data; + } catch (error) { + this.logger.error('Failed to get escrows by signer', error); + this.handleApiError(error, 'Get Escrows By Signer'); + } + } + + async getEscrowsByRole(queryDto: GetEscrowsByRoleDto): Promise { + this.logger.log('Getting escrows by role', { + userKey: queryDto.user_key, + role: queryDto.role, + network: queryDto.network, + }); + + try { + const response = await firstValueFrom( + this.httpService.post(`${this.baseUrl}/helper/get-escrows-by-role`, queryDto, { + headers: this.apiHeaders, + }) + ); + + this.logger.log('Escrows by role retrieved successfully'); + return response.data; + } catch (error) { + this.logger.error('Failed to get escrows by role', error); + this.handleApiError(error, 'Get Escrows By Role'); + } + } + + // ===================== + // VALIDATION METHODS + // ===================== + + private async validateSellerRegistration(sellerKey: string, network: string): Promise { + this.logger.log('Validating seller registration', { seller: sellerKey, network }); + + try { + // Obtener escrows del seller para verificar si está registrado + const escrows = await this.getEscrowsBySigner({ + network: network as any, + signer: sellerKey, + page: 1, + limit: 1, + }); + + // Si es la primera vez, podríamos también verificar la cuenta en Stellar + // Por ahora, si no hay error en la consulta, consideramos que está registrado + this.logger.log('Seller validation completed', { seller: sellerKey }); + + } catch (error) { + // Si falla la consulta, podría ser que no esté registrado o sea un error de red + this.logger.warn('Seller validation warning', { seller: sellerKey, error: error.message }); + + // Dependiendo de la respuesta de la API, podrías querer fallar o continuar + // Por ahora, solo logueamos la advertencia + } + } + + private validateEscrowPayload(initDto: InitializeEscrowDto): void { + if (initDto.type === EscrowType.SINGLE_RELEASE) { + if (!initDto.total_amount) { + throw new BadRequestException('total_amount is required for single-release escrow'); + } + if (initDto.milestones && initDto.milestones.length > 0) { + throw new BadRequestException('milestones should not be provided for single-release escrow'); + } + } else if (initDto.type === EscrowType.MULTI_RELEASE) { + if (!initDto.milestones || initDto.milestones.length === 0) { + throw new BadRequestException('milestones are required for multi-release escrow'); + } + if (initDto.total_amount) { + throw new BadRequestException('total_amount should not be provided for multi-release escrow'); + } + } + + // Validar que todas las claves sean diferentes + const keys = [initDto.seller_key, initDto.approver, initDto.receiver, initDto.dispute_resolver]; + const uniqueKeys = new Set(keys); + if (uniqueKeys.size !== keys.length) { + throw new BadRequestException('All participant keys must be unique'); + } + } + + // ===================== + // INTERNAL DEPLOY METHODS + // ===================== + + private async deploySingleReleaseEscrowInternal(deployDto: any): Promise { + const response = await firstValueFrom( + this.httpService.post(`${this.baseUrl}/deployer/single-release`, deployDto, { + headers: this.apiHeaders, + }) + ); + return response.data; + } + + private async deployMultiReleaseEscrowInternal(deployDto: any): Promise { + const response = await firstValueFrom( + this.httpService.post(`${this.baseUrl}/deployer/multi-release`, deployDto, { + headers: this.apiHeaders, + }) + ); + return response.data; + } + + // ===================== + // UTILITY METHODS + // ===================== + + /** + * Verifica el estado de la API de Trustless Work + */ + async healthCheck(): Promise { + try { + // Nota: Ajusta este endpoint si Trustless Work tiene un endpoint de health + const response = await firstValueFrom( + this.httpService.get(`${this.baseUrl}/health`, { + headers: this.apiHeaders, + timeout: 5000, + }) + ); + + this.logger.log('Trustless Work API is healthy'); + return response.status === 200; + } catch (error) { + this.logger.warn('Trustless Work API health check failed', error.message); + return false; + } + } + + /** + * Obtiene la configuración actual de la API + */ + getApiConfiguration() { + return { + baseUrl: this.baseUrl, + hasApiKey: !!this.configService.get('TRUSTLESS_WORK_API_KEY'), + timeout: 10000, + }; + } + + // ===================== + // ERROR HANDLING + // ===================== + + private handleApiError(error: any, operation: string): never { + const status = error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR; + const message = error.response?.data?.message || error.message || `Trustless Work API Error in ${operation}`; + const details = error.response?.data || {}; + + this.logger.error(`API Error in ${operation}`, { + status, + message, + details, + url: error.config?.url, + }); + + // Mapear códigos de estado específicos + let mappedStatus = status; + if (status === 429) { + mappedStatus = HttpStatus.TOO_MANY_REQUESTS; + } else if (status >= 400 && status < 500) { + mappedStatus = HttpStatus.BAD_REQUEST; + } else if (status >= 500) { + mappedStatus = HttpStatus.SERVICE_UNAVAILABLE; + } + + throw new HttpException( + { + statusCode: mappedStatus, + message, + error: `Trustless Work API Error - ${operation}`, + details, + timestamp: new Date().toISOString(), + }, + mappedStatus, + ); + } +} \ No newline at end of file diff --git a/src/modules/trustlessWork/tests/trustless-work-initialize.test.ts b/src/modules/trustlessWork/tests/trustless-work-initialize.test.ts new file mode 100644 index 0000000..54720d9 --- /dev/null +++ b/src/modules/trustlessWork/tests/trustless-work-initialize.test.ts @@ -0,0 +1,116 @@ +import { NestFactory } from '@nestjs/core'; +import { TrustlessWorkService } from '../services/trustless-work.service'; +import { AppModule } from '@/app.module'; +import { NetworkType } from '../dtos/trustless-work.dto'; + +async function testTrustlessWork() { + console.log('🚀 Starting Trustless Work Integration Test...\n'); + + const app = await NestFactory.createApplicationContext(AppModule); + const trustlessWorkService = app.get(TrustlessWorkService); + + try { + // 1. Test Health Check + console.log('1️⃣ Testing Health Check...'); + const isHealthy = await trustlessWorkService.healthCheck(); + console.log(` Health Status: ${isHealthy ? '✅ Healthy' : '❌ Unhealthy'}\n`); + + // 2. Test API Configuration + console.log('2️⃣ Testing API Configuration...'); + const config = trustlessWorkService.getApiConfiguration(); + console.log(' Configuration:', config); + console.log(''); + + // 3. Test Single Release Escrow Initialization + console.log('3️⃣ Testing Single Release Escrow...'); + try { + const singleReleaseResult = await trustlessWorkService.initializeEscrow({ + type: 'single-release' as any, + seller_key: 'GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ', + approver: 'GB7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ', + receiver: 'GC7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ', + dispute_resolver: 'GD7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ', + total_amount: '1000', + asset: 'USDC', + title: 'Test Single Release Escrow', + description: 'Testing single release escrow creation from script', + }); + + console.log(' ✅ Single Release Success!'); + console.log(' Contract ID:', singleReleaseResult.contract_id); + console.log(' Has Unsigned XDR:', !!singleReleaseResult.unsigned_xdr); + console.log(''); + } catch (error) { + console.log(' ❌ Single Release Failed:', error.message); + console.log(''); + } + + // 4. Test Multi Release Escrow Initialization + console.log('4️⃣ Testing Multi Release Escrow...'); + try { + const multiReleaseResult = await trustlessWorkService.initializeEscrow({ + type: 'multi-release' as any, + seller_key: 'GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ', + approver: 'GB7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ', + receiver: 'GC7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ', + dispute_resolver: 'GD7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ', + milestones: [ + { + title: 'Milestone 1', + description: 'First milestone', + amount: '500', + }, + { + title: 'Milestone 2', + description: 'Second milestone', + amount: '500', + }, + ], + asset: 'USDC', + title: 'Test Multi Release Escrow', + description: 'Testing multi release escrow creation from script', + }); + + console.log(' ✅ Multi Release Success!'); + console.log(' Contract ID:', multiReleaseResult.contract_id); + console.log(' Has Unsigned XDR:', !!multiReleaseResult.unsigned_xdr); + console.log(''); + } catch (error) { + console.log(' ❌ Multi Release Failed:', error.message); + console.log(''); + } + + // 5. Test Error Cases + console.log('5️⃣ Testing Error Cases...'); + try { + await trustlessWorkService.initializeEscrow({ + type: 'single-release' as any, + seller_key: 'INVALID_KEY', + approver: 'GB7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ', + receiver: 'GC7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ', + dispute_resolver: 'GD7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ', + total_amount: '1000', + asset: 'USDC', + title: 'Test Escrow', + description: 'Test Description', + }); + console.log(' ❌ Should have failed with invalid key'); + } catch (error) { + console.log(' ✅ Correctly caught error:', error.message); + } + + console.log('\n🎉 Trustless Work Integration Test Complete!'); + + } catch (error) { + console.error('❌ Test failed:', error); + } finally { + await app.close(); + } +} + +// Ejecutar solo si se llama directamente +if (require.main === module) { + testTrustlessWork().catch(console.error); +} + +export { testTrustlessWork }; \ No newline at end of file diff --git a/src/modules/trustlessWork/tests/trustless-work.service.ts b/src/modules/trustlessWork/tests/trustless-work.service.ts new file mode 100644 index 0000000..d4ba23e --- /dev/null +++ b/src/modules/trustlessWork/tests/trustless-work.service.ts @@ -0,0 +1,209 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { TrustlessWorkService } from '../services/trustless-work.service'; +import { of, throwError } from 'rxjs'; +import { ApproveMilestoneDto, DeploySingleReleaseEscrowDto, EscrowType, FundEscrowDto, NetworkType } from '../dtos/trustless-work.dto'; + +describe('TrustlessWorkService', () => { + let service: TrustlessWorkService; + let httpService: HttpService; + let configService: ConfigService; + + const mockHttpService = { + post: jest.fn(), + get: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn().mockReturnValue('https://api.trustlesswork.com'), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TrustlessWorkService, + { provide: HttpService, useValue: mockHttpService }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + service = module.get(TrustlessWorkService); + httpService = module.get(HttpService); + configService = module.get(ConfigService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('deploySingleReleaseEscrow', () => { + it('should deploy a single release escrow successfully', async () => { + const mockResponse = { + data: { + success: true, + contract_id: 'CA123...', + unsigned_xdr: 'AAAAAG...', + }, + }; + + mockHttpService.post.mockReturnValue(of(mockResponse)); + + const deployDto = { + network: 'testnet' as const, + service_provider: 'GA123...', + approver: 'GB456...', + receiver: 'GC789...', + dispute_resolver: 'GD012...', + total_amount: '1000', + asset: 'USDC', + title: 'Test Escrow', + description: 'Test Description', + }; + + const result = await service.deploySingleReleaseEscrow(deployDto); + + expect(result).toEqual(mockResponse.data); + expect(mockHttpService.post).toHaveBeenCalledWith('/deployer/single-release', deployDto); + }); + + it('should handle deployment errors', async () => { + mockHttpService.post.mockReturnValue(throwError(() => new Error('API Error'))); + + const deployDto: DeploySingleReleaseEscrowDto = { + service_provider: 'GA123...', + approver: 'GB456...', + receiver: 'GC789...', + dispute_resolver: 'GD012...', + total_amount: '1000', + asset: 'USDC', + title: 'Test Escrow', + description: 'Test Description', + }; + + await expect(service.deploySingleReleaseEscrow(deployDto)).rejects.toThrow('API Error'); + }); + }); + + describe('fundEscrow', () => { + it('should fund an escrow successfully', async () => { + const mockResponse = { + data: { + success: true, + unsigned_xdr: 'AAAAAG...', + }, + }; + + mockHttpService.post.mockReturnValue(of(mockResponse)); + + const fundDto: FundEscrowDto = { + contract_id: 'CA123...', + funder_key: 'GA123...', + amount: '1000', + }; + + const result = await service.fundEscrow(EscrowType.SINGLE_RELEASE, fundDto); + + expect(result).toEqual(mockResponse.data); + expect(mockHttpService.post).toHaveBeenCalledWith('/escrow/single-release/fund-escrow', fundDto); + }); + }); + + describe('getEscrowDetails', () => { + it('should retrieve escrow details successfully', async () => { + const mockResponse = { + data: { + contract_id: 'CA123...', + status: 'active', + balance: '1000', + milestones: [], + }, + }; + + mockHttpService.post.mockReturnValue(of(mockResponse)); + + const queryDto = { + network: NetworkType.TESTNET, + contract_id: 'CA123...', + }; + + const result = await service.getEscrowDetails(EscrowType.SINGLE_RELEASE, queryDto); + + expect(result).toEqual(mockResponse.data); + expect(mockHttpService.post).toHaveBeenCalledWith('/escrow/single-release/get-escrow', queryDto); + }); + }); + + describe('approveMilestone', () => { + it('should approve a milestone successfully', async () => { + const mockResponse = { + data: { + success: true, + unsigned_xdr: 'AAAAAG...', + }, + }; + + mockHttpService.post.mockReturnValue(of(mockResponse)); + + const approveDto: ApproveMilestoneDto = { + contract_id: 'CA123...', + approver_key: 'GB456...', + milestone_index: 0, + }; + + const result = await service.approveMilestone(EscrowType.MULTI_RELEASE, approveDto); + + expect(result).toEqual(mockResponse.data); + expect(mockHttpService.post).toHaveBeenCalledWith('/escrow/multi-release/approve-milestone', approveDto); + }); + }); + + describe('releaseFunds', () => { + it('should release funds for single release escrow', async () => { + const mockResponse = { + data: { + success: true, + unsigned_xdr: 'AAAAAG...', + }, + }; + + mockHttpService.post.mockReturnValue(of(mockResponse)); + + const releaseDto = { + network: 'testnet' as const, + contract_id: 'CA123...', + releaser_key: 'GA123...', + }; + + const result = await service.releaseFunds(EscrowType.SINGLE_RELEASE, releaseDto); + + expect(result).toEqual(mockResponse.data); + expect(mockHttpService.post).toHaveBeenCalledWith('/escrow/single-release/release-funds', releaseDto); + }); + }); + + describe('disputeEscrow', () => { + it('should create a dispute successfully', async () => { + const mockResponse = { + data: { + success: true, + unsigned_xdr: 'AAAAAG...', + }, + }; + + mockHttpService.post.mockReturnValue(of(mockResponse)); + + const disputeDto = { + network: 'testnet' as const, + contract_id: 'CA123...', + disputer_key: 'GA123...', + reason: 'Work not completed as agreed', + }; + + const result = await service.disputeEscrow(EscrowType.SINGLE_RELEASE, disputeDto); + + expect(result).toEqual(mockResponse.data); + expect(mockHttpService.post).toHaveBeenCalledWith('/escrow/single-release/dispute-escrow', disputeDto); + }); + }); +}); \ No newline at end of file diff --git a/src/modules/trustlessWork/trustless-work.module.ts b/src/modules/trustlessWork/trustless-work.module.ts new file mode 100644 index 0000000..f9801a4 --- /dev/null +++ b/src/modules/trustlessWork/trustless-work.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { ConfigModule } from '@nestjs/config'; +import { TrustlessWorkController } from './controllers/trustless-work.controller'; +import { TrustlessWorkService } from './services/trustless-work.service'; + +@Module({ + imports: [ + HttpModule.register({ + timeout: 10000, + maxRedirects: 5, + }), + ConfigModule, + ], + controllers: [TrustlessWorkController], + providers: [TrustlessWorkService], + exports: [TrustlessWorkService], +}) +export class TrustlessWorkModule {} \ No newline at end of file