Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/backend/src/claims/claims.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
96 changes: 44 additions & 52 deletions app/backend/src/claims/claims.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,19 +141,17 @@ describe('ClaimsService', () => {
.mockResolvedValue(mockClaim);
jest
.spyOn(prismaService, '$transaction')
.mockImplementation(
async (callback: (tx: any) => Promise<unknown>) => {
await Promise.resolve();
return callback({
claim: {
update: jest.fn().mockResolvedValue({
...mockClaim,
status: ClaimStatus.disbursed,
}),
},
});
},
);
.mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
await Promise.resolve();
return callback({
claim: {
update: jest.fn().mockResolvedValue({
...mockClaim,
status: ClaimStatus.disbursed,
}),
},
});
});

await service.disburse('claim-123');

Expand All @@ -172,19 +170,17 @@ describe('ClaimsService', () => {
.mockResolvedValue(mockClaim);
jest
.spyOn(prismaService, '$transaction')
.mockImplementation(
async (callback: (tx: any) => Promise<unknown>) => {
await Promise.resolve();
return callback({
claim: {
update: jest.fn().mockResolvedValue({
...mockClaim,
status: ClaimStatus.disbursed,
}),
},
});
},
);
.mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
await Promise.resolve();
return callback({
claim: {
update: jest.fn().mockResolvedValue({
...mockClaim,
status: ClaimStatus.disbursed,
}),
},
});
});

await service.disburse('claim-123');

Expand All @@ -206,19 +202,17 @@ describe('ClaimsService', () => {
.mockResolvedValue(mockClaim);
jest
.spyOn(prismaService, '$transaction')
.mockImplementation(
async (callback: (tx: any) => Promise<unknown>) => {
await Promise.resolve();
return callback({
claim: {
update: jest.fn().mockResolvedValue({
...mockClaim,
status: ClaimStatus.disbursed,
}),
},
});
},
);
.mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
await Promise.resolve();
return callback({
claim: {
update: jest.fn().mockResolvedValue({
...mockClaim,
status: ClaimStatus.disbursed,
}),
},
});
});

await service.disburse('claim-123');

Expand Down Expand Up @@ -333,19 +327,17 @@ describe('ClaimsService', () => {
.mockResolvedValue(mockClaim);
const transactionSpy = jest
.spyOn(prismaService, '$transaction')
.mockImplementation(
async (callback: (tx: any) => Promise<unknown>) => {
await Promise.resolve();
return callback({
claim: {
update: jest.fn().mockResolvedValue({
...mockClaim,
status: ClaimStatus.disbursed,
}),
},
});
},
);
.mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
await Promise.resolve();
return callback({
claim: {
update: jest.fn().mockResolvedValue({
...mockClaim,
status: ClaimStatus.disbursed,
}),
},
});
});

await service.disburse('claim-123');

Expand Down
37 changes: 36 additions & 1 deletion app/backend/src/claims/claims.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@
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,
Expand All @@ -67,7 +70,10 @@
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;
}
Expand Down Expand Up @@ -151,11 +157,16 @@
// 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;
Expand Down Expand Up @@ -254,6 +265,30 @@
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) {

Check warning on line 280 in app/backend/src/claims/claims.service.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe member access .metadata on an `any` value
return claim.metadata.tokenAddress;

Check warning on line 281 in app/backend/src/claims/claims.service.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe member access .metadata on an `any` value

Check warning on line 281 in app/backend/src/claims/claims.service.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe return of a value of type `any`
}

// If campaign has tokenAddress in metadata, use it
if (claim.campaign?.metadata?.tokenAddress) {

Check warning on line 285 in app/backend/src/claims/claims.service.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe member access .campaign on an `any` value
return claim.campaign.metadata.tokenAddress;

Check warning on line 286 in app/backend/src/claims/claims.service.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe member access .campaign on an `any` value

Check warning on line 286 in app/backend/src/claims/claims.service.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe return of a value of type `any`
}

return defaultTokenAddress;
}

async archive(id: string) {
return this.transitionStatus(
id,
Expand Down
14 changes: 14 additions & 0 deletions app/backend/src/claims/dto/create-claim.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
IsNumber,
IsOptional,
Min,
Matches,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
Expand Down Expand Up @@ -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).',
Expand Down
81 changes: 79 additions & 2 deletions app/backend/src/onchain/aid-escrow.service.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 {
Expand All @@ -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,
Expand All @@ -45,13 +99,15 @@ export class AidEscrowService {
this.logger.debug('Aid package created successfully:', {
packageId: result.packageId,
transactionHash: result.transactionHash,
tokenAddress: dto.tokenAddress,
});

return result;
}

/**
* Create multiple aid packages in a batch
* Performs token balance check for total amount before creation
*/
async batchCreateAidPackages(
dto: BatchCreateAidPackagesDto,
Expand All @@ -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,
Expand All @@ -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;
Expand Down
Loading
Loading