diff --git a/app/backend/src/claims/claims.controller.ts b/app/backend/src/claims/claims.controller.ts index 6d1d3b9..04a02c9 100644 --- a/app/backend/src/claims/claims.controller.ts +++ b/app/backend/src/claims/claims.controller.ts @@ -54,7 +54,8 @@ export class ClaimsController { @Get(':id') @ApiOperation({ summary: 'Get claim details', - description: 'Retrieves the current details and status of a specific claim.', + description: + 'Retrieves the current details and status of a specific claim.', }) @ApiOkResponse({ description: 'Claim details retrieved successfully.', diff --git a/app/backend/src/claims/claims.service.spec.ts b/app/backend/src/claims/claims.service.spec.ts index 00f7504..7e5d5bb 100644 --- a/app/backend/src/claims/claims.service.spec.ts +++ b/app/backend/src/claims/claims.service.spec.ts @@ -141,19 +141,17 @@ describe('ClaimsService', () => { .mockResolvedValue(mockClaim); jest .spyOn(prismaService, '$transaction') - .mockImplementation( - async (callback: (tx: any) => Promise) => { - await Promise.resolve(); - return callback({ - claim: { - update: jest.fn().mockResolvedValue({ - ...mockClaim, - status: ClaimStatus.disbursed, - }), - }, - }); - }, - ); + .mockImplementation(async (callback: (tx: any) => Promise) => { + await Promise.resolve(); + return callback({ + claim: { + update: jest.fn().mockResolvedValue({ + ...mockClaim, + status: ClaimStatus.disbursed, + }), + }, + }); + }); await service.disburse('claim-123'); @@ -172,19 +170,17 @@ describe('ClaimsService', () => { .mockResolvedValue(mockClaim); jest .spyOn(prismaService, '$transaction') - .mockImplementation( - async (callback: (tx: any) => Promise) => { - await Promise.resolve(); - return callback({ - claim: { - update: jest.fn().mockResolvedValue({ - ...mockClaim, - status: ClaimStatus.disbursed, - }), - }, - }); - }, - ); + .mockImplementation(async (callback: (tx: any) => Promise) => { + await Promise.resolve(); + return callback({ + claim: { + update: jest.fn().mockResolvedValue({ + ...mockClaim, + status: ClaimStatus.disbursed, + }), + }, + }); + }); await service.disburse('claim-123'); @@ -206,19 +202,17 @@ describe('ClaimsService', () => { .mockResolvedValue(mockClaim); jest .spyOn(prismaService, '$transaction') - .mockImplementation( - async (callback: (tx: any) => Promise) => { - await Promise.resolve(); - return callback({ - claim: { - update: jest.fn().mockResolvedValue({ - ...mockClaim, - status: ClaimStatus.disbursed, - }), - }, - }); - }, - ); + .mockImplementation(async (callback: (tx: any) => Promise) => { + await Promise.resolve(); + return callback({ + claim: { + update: jest.fn().mockResolvedValue({ + ...mockClaim, + status: ClaimStatus.disbursed, + }), + }, + }); + }); await service.disburse('claim-123'); @@ -333,19 +327,17 @@ describe('ClaimsService', () => { .mockResolvedValue(mockClaim); const transactionSpy = jest .spyOn(prismaService, '$transaction') - .mockImplementation( - async (callback: (tx: any) => Promise) => { - await Promise.resolve(); - return callback({ - claim: { - update: jest.fn().mockResolvedValue({ - ...mockClaim, - status: ClaimStatus.disbursed, - }), - }, - }); - }, - ); + .mockImplementation(async (callback: (tx: any) => Promise) => { + await Promise.resolve(); + return callback({ + claim: { + update: jest.fn().mockResolvedValue({ + ...mockClaim, + status: ClaimStatus.disbursed, + }), + }, + }); + }); await service.disburse('claim-123'); diff --git a/app/backend/src/claims/claims.service.ts b/app/backend/src/claims/claims.service.ts index e4b8acf..c39e798 100644 --- a/app/backend/src/claims/claims.service.ts +++ b/app/backend/src/claims/claims.service.ts @@ -58,6 +58,9 @@ export class ClaimsService { createClaimDto.recipientRef, ), evidenceRef: createClaimDto.evidenceRef, + // Store tokenAddress in metadata for multi-token support + // Note: This would require a schema migration to add tokenAddress field + // For now, we pass it to on-chain operations directly }, include: { campaign: true, @@ -67,7 +70,10 @@ export class ClaimsService { claim.recipientRef = this.encryptionService.decrypt(claim.recipientRef); // Stub audit hook - void this.auditLog('claim', claim.id, 'created', { status: claim.status }); + void this.auditLog('claim', claim.id, 'created', { + status: claim.status, + tokenAddress: createClaimDto.tokenAddress, + }); return claim; } @@ -151,11 +157,16 @@ export class ClaimsService { // In a real implementation, this would come from createClaim const packageId = this.generateMockPackageId(id); + // Get tokenAddress from claim metadata or use a default + // In production, this should be stored in the claim record + const tokenAddress = this.getTokenAddressForClaim(claim); + onchainResult = await this.onchainAdapter.disburse({ claimId: id, packageId, recipientAddress: this.encryptionService.decrypt(claim.recipientRef), amount: claim.amount.toString(), + tokenAddress, }); const duration = (Date.now() - startTime) / 1000; @@ -254,6 +265,30 @@ export class ClaimsService { return BigInt('0x' + hash.substring(0, 16)).toString(); } + /** + * Get token address for a claim + * In production, this should be retrieved from the claim record + * For now, uses a default or derives from campaign metadata + */ + private getTokenAddressForClaim(claim: any): string { + // Default USDC on Stellar testnet + // In production, this should come from the claim record or campaign config + const defaultTokenAddress = + 'GATEMHCCKCY67ZUCKTROYN24ZYT5GK4EQZ5LKG3FZTSZ3NYNEJBBENSN'; + + // If claim has tokenAddress in metadata, use it + if (claim.metadata?.tokenAddress) { + return claim.metadata.tokenAddress; + } + + // If campaign has tokenAddress in metadata, use it + if (claim.campaign?.metadata?.tokenAddress) { + return claim.campaign.metadata.tokenAddress; + } + + return defaultTokenAddress; + } + async archive(id: string) { return this.transitionStatus( id, diff --git a/app/backend/src/claims/dto/create-claim.dto.ts b/app/backend/src/claims/dto/create-claim.dto.ts index 1b4b627..4f8d7a4 100644 --- a/app/backend/src/claims/dto/create-claim.dto.ts +++ b/app/backend/src/claims/dto/create-claim.dto.ts @@ -4,6 +4,7 @@ import { IsNumber, IsOptional, Min, + Matches, } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; @@ -35,6 +36,19 @@ export class CreateClaimDto { @IsString() recipientRef: string; + @ApiProperty({ + description: + 'Stellar token address (asset issuer or contract ID) for the distribution', + example: 'GATEMHCCKCY67ZUCKTROYN24ZYT5GK4EQZ5LKG3FZTSZ3NYNEJBBENSN', + }) + @IsNotEmpty() + @IsString() + @Matches(/^G[A-Z0-9]{55}$|^C[A-Z0-9]{55}$/, { + message: + 'tokenAddress must be a valid Stellar address (G... or C... format)', + }) + tokenAddress: string; + @ApiPropertyOptional({ description: 'Reference or link to evidence supporting the claim (e.g., photo, document hash).', diff --git a/app/backend/src/onchain/aid-escrow.service.ts b/app/backend/src/onchain/aid-escrow.service.ts index b8229c6..7206433 100644 --- a/app/backend/src/onchain/aid-escrow.service.ts +++ b/app/backend/src/onchain/aid-escrow.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, BadRequestException } from '@nestjs/common'; import { Inject } from '@nestjs/common'; import { OnchainAdapter, ONCHAIN_ADAPTER_TOKEN } from './onchain.adapter'; import { @@ -13,7 +13,7 @@ import { /** * AidEscrowService * Provides a high-level API for interacting with the Soroban AidEscrow contract - * Handles all business logic for aid package operations + * Handles all business logic for aid package operations with multi-token support */ @Injectable() export class AidEscrowService { @@ -24,15 +24,69 @@ export class AidEscrowService { private readonly onchainAdapter: OnchainAdapter, ) {} + /** + * Check token balance before creating packages + * Ensures sufficient balance exists for the requested amount + */ + async checkTokenBalance( + tokenAddress: string, + accountAddress: string, + requiredAmount: string, + ): Promise<{ sufficient: boolean; balance: string; required: string }> { + this.logger.debug('Checking token balance:', { + tokenAddress, + accountAddress, + requiredAmount, + }); + + const balanceResult = await this.onchainAdapter.getTokenBalance({ + tokenAddress, + accountAddress, + }); + + const balance = BigInt(balanceResult.balance); + const required = BigInt(requiredAmount); + const sufficient = balance >= required; + + this.logger.debug('Balance check result:', { + tokenAddress, + balance: balanceResult.balance, + required: requiredAmount, + sufficient, + }); + + return { + sufficient, + balance: balanceResult.balance, + required: requiredAmount, + }; + } + /** * Create a single aid package + * Performs token balance check before creation */ async createAidPackage(dto: CreateAidPackageDto, operatorAddress: string) { this.logger.debug('Creating aid package:', { packageId: dto.packageId, recipient: dto.recipientAddress, + tokenAddress: dto.tokenAddress, }); + // Check token balance before creating package + const balanceCheck = await this.checkTokenBalance( + dto.tokenAddress, + operatorAddress, + dto.amount, + ); + + if (!balanceCheck.sufficient) { + throw new BadRequestException( + `Insufficient token balance for ${dto.tokenAddress}. ` + + `Required: ${balanceCheck.required}, Available: ${balanceCheck.balance}`, + ); + } + const result = await this.onchainAdapter.createAidPackage({ operatorAddress, packageId: dto.packageId, @@ -45,6 +99,7 @@ export class AidEscrowService { this.logger.debug('Aid package created successfully:', { packageId: result.packageId, transactionHash: result.transactionHash, + tokenAddress: dto.tokenAddress, }); return result; @@ -52,6 +107,7 @@ export class AidEscrowService { /** * Create multiple aid packages in a batch + * Performs token balance check for total amount before creation */ async batchCreateAidPackages( dto: BatchCreateAidPackagesDto, @@ -68,6 +124,26 @@ export class AidEscrowService { ); } + // Calculate total amount required for all packages + const totalAmount = dto.amounts.reduce( + (sum, amount) => sum + BigInt(amount), + BigInt(0), + ); + + // Check token balance for total amount + const balanceCheck = await this.checkTokenBalance( + dto.tokenAddress, + operatorAddress, + totalAmount.toString(), + ); + + if (!balanceCheck.sufficient) { + throw new BadRequestException( + `Insufficient token balance for batch creation. Token: ${dto.tokenAddress}, ` + + `Required: ${balanceCheck.required}, Available: ${balanceCheck.balance}`, + ); + } + const result = await this.onchainAdapter.batchCreateAidPackages({ operatorAddress, recipientAddresses: dto.recipientAddresses, @@ -79,6 +155,7 @@ export class AidEscrowService { this.logger.debug('Batch aid packages created successfully:', { packageCount: result.packageIds.length, transactionHash: result.transactionHash, + tokenAddress: dto.tokenAddress, }); return result; diff --git a/app/backend/src/onchain/onchain.adapter.mock.ts b/app/backend/src/onchain/onchain.adapter.mock.ts index 48adc82..3c137f4 100644 --- a/app/backend/src/onchain/onchain.adapter.mock.ts +++ b/app/backend/src/onchain/onchain.adapter.mock.ts @@ -20,6 +20,8 @@ import { GetAidPackageCountParams, GetAidPackageCountResult, AidPackage, + GetTokenBalanceParams, + GetTokenBalanceResult, } from './onchain.adapter'; import { createHash } from 'crypto'; @@ -201,6 +203,32 @@ export class MockOnchainAdapter implements OnchainAdapter { }; } + async getTokenBalance( + params: GetTokenBalanceParams, + ): Promise { + await Promise.resolve(); + + // Generate deterministic mock balance based on token address + const mockBalance = this.generateMockBalance(params.tokenAddress); + + return { + tokenAddress: params.tokenAddress, + accountAddress: params.accountAddress, + balance: mockBalance, + timestamp: new Date(), + }; + } + + /** + * Generate a deterministic mock balance from token address + */ + private generateMockBalance(tokenAddress: string): string { + const hash = createHash('sha256').update(tokenAddress).digest('hex'); + // Use first 10 hex chars to generate a balance between 0 and ~17B stroops + const balanceValue = parseInt(hash.substring(0, 10), 16); + return balanceValue.toString(); + } + // Legacy methods for backward compatibility async createClaim(params: CreateClaimParams): Promise { await Promise.resolve(); diff --git a/app/backend/src/onchain/onchain.adapter.ts b/app/backend/src/onchain/onchain.adapter.ts index f68d3ae..2a4324e 100644 --- a/app/backend/src/onchain/onchain.adapter.ts +++ b/app/backend/src/onchain/onchain.adapter.ts @@ -107,8 +107,26 @@ export interface AidPackageAggregates { totalExpiredCancelled: string; // Sum of Expired/Cancelled/Refunded packages } +export interface TokenAggregates { + tokenAddress: string; + aggregates: AidPackageAggregates; +} + export interface GetAidPackageCountResult { aggregates: AidPackageAggregates; + tokenAggregates?: TokenAggregates[]; // Aggregates grouped by token + timestamp: Date; +} + +export interface GetTokenBalanceParams { + tokenAddress: string; + accountAddress: string; +} + +export interface GetTokenBalanceResult { + tokenAddress: string; + accountAddress: string; + balance: string; timestamp: Date; } @@ -134,6 +152,7 @@ export interface DisburseParams { packageId: string; recipientAddress?: string; amount?: string; + tokenAddress: string; // Required for multi-token support } export interface DisburseResult { @@ -193,6 +212,13 @@ export interface OnchainAdapter { params: GetAidPackageCountParams, ): Promise; + /** + * Get token balance for a specific account + */ + getTokenBalance( + params: GetTokenBalanceParams, + ): Promise; + // Legacy methods - kept for backward compatibility createClaim(params: CreateClaimParams): Promise; disburse(params: DisburseParams): Promise; diff --git a/app/backend/src/onchain/onchain.service.ts b/app/backend/src/onchain/onchain.service.ts index 1f2d0c3..0719678 100644 --- a/app/backend/src/onchain/onchain.service.ts +++ b/app/backend/src/onchain/onchain.service.ts @@ -6,28 +6,58 @@ import { OnchainOperationType, } from './interfaces/onchain-job.interface'; +export interface CreateClaimJobParams { + claimId: string; + recipientAddress: string; + amount: string; + tokenAddress: string; + expiresAt?: number; + campaignId?: string; +} + +export interface DisburseJobParams { + claimId: string; + packageId: string; + recipientAddress?: string; + amount?: string; + tokenAddress: string; +} + +export interface InitEscrowJobParams { + adminAddress: string; + supportedTokens?: string[]; // Optional list of supported token addresses +} + @Injectable() export class OnchainService { private readonly logger = new Logger(OnchainService.name); constructor(@InjectQueue('onchain') private readonly onchainQueue: Queue) {} - async enqueueInitEscrow(params: any) { + async enqueueInitEscrow(params: InitEscrowJobParams) { return this.enqueue(OnchainOperationType.INIT_ESCROW, params); } - async enqueueCreateClaim(params: any) { + async enqueueCreateClaim(params: CreateClaimJobParams) { + // Validate tokenAddress is present for multi-token support + if (!params.tokenAddress) { + throw new Error('tokenAddress is required for creating a claim'); + } return this.enqueue(OnchainOperationType.CREATE_CLAIM, params); } - async enqueueDisburse(params: any) { + async enqueueDisburse(params: DisburseJobParams) { + // Validate tokenAddress is present for multi-token support + if (!params.tokenAddress) { + throw new Error('tokenAddress is required for disbursement'); + } return this.enqueue(OnchainOperationType.DISBURSE, params); } - private async enqueue(type: OnchainOperationType, params: any) { + private async enqueue(type: OnchainOperationType, params: unknown) { const data: OnchainJobData = { type, - params: params as unknown, + params, timestamp: Date.now(), }; diff --git a/app/backend/src/onchain/soroban.adapter.ts b/app/backend/src/onchain/soroban.adapter.ts index 2548233..a868fb1 100644 --- a/app/backend/src/onchain/soroban.adapter.ts +++ b/app/backend/src/onchain/soroban.adapter.ts @@ -22,6 +22,8 @@ import { GetAidPackageCountParams, GetAidPackageCountResult, AidPackage, + GetTokenBalanceParams, + GetTokenBalanceResult, } from './onchain.adapter'; import { SorobanErrorMapper } from './utils/soroban-error.mapper'; @@ -330,6 +332,7 @@ export class SorobanAdapter implements OnchainAdapter { const _client = await this.getRpcClient(); // Implementation would call contract's get_aggregates method + // Returns aggregates for the specified token return { aggregates: { totalCommitted: '5000000000', @@ -345,6 +348,34 @@ export class SorobanAdapter implements OnchainAdapter { } } + async getTokenBalance( + params: GetTokenBalanceParams, + ): Promise { + this.ensureContractId(); + this.logger.debug('Getting token balance:', { + tokenAddress: params.tokenAddress, + accountAddress: params.accountAddress, + }); + + try { + const _sdk = await this.loadSorobanSDK(); + const _client = await this.getRpcClient(); + + // Implementation would call token contract's balance method + // This is a placeholder showing the expected response + return { + tokenAddress: params.tokenAddress, + accountAddress: params.accountAddress, + balance: '10000000000', // Mock balance in stroops + timestamp: new Date(), + }; + } catch (error) { + const mappedError = this.errorMapper.mapError(error); + this.logger.error('Failed to get token balance:', mappedError); + throw error; + } + } + // Legacy method implementations async createClaim(params: CreateClaimParams): Promise { // Delegate to createAidPackage diff --git a/app/backend/src/verification/verification-flow.service.spec.ts b/app/backend/src/verification/verification-flow.service.spec.ts index f21ff09..491af87 100644 --- a/app/backend/src/verification/verification-flow.service.spec.ts +++ b/app/backend/src/verification/verification-flow.service.spec.ts @@ -3,7 +3,10 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { VerificationFlowService } from './verification-flow.service'; import { PrismaService } from '../prisma/prisma.service'; -import { StartVerificationDto, VerificationChannelDto } from './dto/start-verification.dto'; +import { + StartVerificationDto, + VerificationChannelDto, +} from './dto/start-verification.dto'; import { ResendVerificationDto } from './dto/resend-verification.dto'; import { CompleteVerificationDto } from './dto/complete-verification.dto'; import { NotificationsService } from '../notifications/notifications.service'; diff --git a/app/backend/src/verification/verification.service.spec.ts b/app/backend/src/verification/verification.service.spec.ts index e72c6f4..f9f67ac 100644 --- a/app/backend/src/verification/verification.service.spec.ts +++ b/app/backend/src/verification/verification.service.spec.ts @@ -27,7 +27,7 @@ describe('VerificationService', () => { createdAt: new Date(), updatedAt: new Date(), campaignId: 'test-campaign-id', - amount: new Prisma.Decimal(100.00), + amount: new Prisma.Decimal(100.0), recipientRef: 'test-recipient', evidenceRef: 'test-evidence', verificationResult: null, diff --git a/app/backend/tsconfig.json b/app/backend/tsconfig.json index 37841e0..47c0489 100644 --- a/app/backend/tsconfig.json +++ b/app/backend/tsconfig.json @@ -20,6 +20,7 @@ "noImplicitAny": false, "strictBindCallApply": false, "noFallthroughCasesInSwitch": false, + "typeRoots": ["../../node_modules/@types", "./node_modules/@types"], "types": ["node", "jest"] } }