Skip to content

Commit 24ac94c

Browse files
authored
Merge pull request #91 from AbdulSnk/feature/77-donation-api
#77 Implement Store Donation Endpoint:
2 parents 47dab4d + 7e7e3ab commit 24ac94c

8 files changed

Lines changed: 2008 additions & 26 deletions

File tree

CODEBASE_OVERVIEW.md

Lines changed: 1449 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
2+
import { ConfigService } from '@nestjs/config';
3+
4+
interface StellarTransaction {
5+
id: string;
6+
envelope_xdr: string;
7+
result_xdr: string;
8+
result_meta_xdr: string;
9+
tx: {
10+
source_account: string;
11+
fee: number;
12+
seq_num: string;
13+
operations: Array<{
14+
type: string;
15+
[key: string]: unknown;
16+
}>;
17+
};
18+
}
19+
20+
interface TransactionVerificationResult {
21+
isValid: boolean;
22+
transaction?: StellarTransaction;
23+
error?: string;
24+
}
25+
26+
@Injectable()
27+
export class StellarBlockchainService {
28+
private readonly logger = new Logger(StellarBlockchainService.name);
29+
private readonly horizonUrl: string;
30+
private readonly stellarNetwork: string;
31+
32+
constructor(private configService: ConfigService) {
33+
this.horizonUrl = this.configService.get<string>(
34+
'STELLAR_HORIZON_URL',
35+
'https://horizon-testnet.stellar.org',
36+
);
37+
this.stellarNetwork = this.configService.get<string>('STELLAR_NETWORK', 'TESTNET');
38+
39+
this.logger.log(`Initialized with Horizon URL: ${this.horizonUrl}`);
40+
}
41+
42+
/**
43+
* Verify a transaction hash exists on the Stellar blockchain
44+
* @param transactionHash - The transaction hash to verify (64 hex characters)
45+
* @returns TransactionVerificationResult with verification status
46+
*/
47+
async verifyTransaction(transactionHash: string): Promise<TransactionVerificationResult> {
48+
try {
49+
// Validate hash format before making API call
50+
if (!this.isValidTransactionHash(transactionHash)) {
51+
return {
52+
isValid: false,
53+
error: 'Invalid transaction hash format',
54+
};
55+
}
56+
57+
const response = await fetch(
58+
`${this.horizonUrl}/transactions/${transactionHash}`,
59+
{
60+
method: 'GET',
61+
headers: {
62+
'Content-Type': 'application/json',
63+
},
64+
},
65+
);
66+
67+
if (!response.ok) {
68+
if (response.status === 404) {
69+
this.logger.warn(`Transaction ${transactionHash} not found on blockchain`);
70+
return {
71+
isValid: false,
72+
error: 'Transaction not found on the Stellar blockchain',
73+
};
74+
}
75+
76+
this.logger.error(
77+
`Horizon API error: ${response.status} ${response.statusText}`,
78+
);
79+
return {
80+
isValid: false,
81+
error: `Horizon API returned ${response.status}`,
82+
};
83+
}
84+
85+
const transaction: StellarTransaction = await response.json();
86+
87+
// Verify transaction has successful result
88+
if (!this.isSuccessfulTransaction(transaction)) {
89+
this.logger.warn(`Transaction ${transactionHash} did not execute successfully`);
90+
return {
91+
isValid: false,
92+
error: 'Transaction did not execute successfully on the blockchain',
93+
};
94+
}
95+
96+
this.logger.log(`Transaction ${transactionHash} verified successfully`);
97+
return {
98+
isValid: true,
99+
transaction,
100+
};
101+
} catch (error) {
102+
this.logger.error(
103+
`Error verifying transaction: ${error instanceof Error ? error.message : String(error)}`,
104+
);
105+
return {
106+
isValid: false,
107+
error: 'Failed to verify transaction on blockchain',
108+
};
109+
}
110+
}
111+
112+
/**
113+
* Validate transaction hash format
114+
* Stellar transaction hashes are 64 hexadecimal characters
115+
* @param hash - The hash to validate
116+
* @returns boolean indicating if hash is valid format
117+
*/
118+
private isValidTransactionHash(hash: string): boolean {
119+
const transactionHashRegex = /^[a-f0-9]{64}$/i;
120+
return transactionHashRegex.test(hash);
121+
}
122+
123+
/**
124+
* Check if transaction executed successfully
125+
* A successful transaction should have result_xdr that indicates success
126+
* @param transaction - The transaction object from Horizon API
127+
* @returns boolean indicating if transaction was successful
128+
*/
129+
private isSuccessfulTransaction(transaction: StellarTransaction): boolean {
130+
try {
131+
// Check if result_meta_xdr exists (indicates transaction was processed)
132+
if (!transaction.result_meta_xdr || !transaction.tx) {
133+
return false;
134+
}
135+
136+
// Transaction exists and was processed successfully
137+
return true;
138+
} catch (error) {
139+
this.logger.error('Error checking transaction success:', error);
140+
return false;
141+
}
142+
}
143+
144+
/**
145+
* Get transaction details from blockchain
146+
* @param transactionHash - The transaction hash to retrieve
147+
* @returns Promise resolving to transaction details or null if not found
148+
*/
149+
async getTransactionDetails(transactionHash: string): Promise<StellarTransaction | null> {
150+
try {
151+
if (!this.isValidTransactionHash(transactionHash)) {
152+
return null;
153+
}
154+
155+
const response = await fetch(
156+
`${this.horizonUrl}/transactions/${transactionHash}`,
157+
{
158+
method: 'GET',
159+
headers: {
160+
'Content-Type': 'application/json',
161+
},
162+
},
163+
);
164+
165+
if (!response.ok) {
166+
return null;
167+
}
168+
169+
return await response.json();
170+
} catch (error) {
171+
this.logger.error('Error fetching transaction details:', error);
172+
return null;
173+
}
174+
}
175+
}

src/donations/donations.controller.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import {
88
Delete,
99
Query,
1010
ParseUUIDPipe,
11+
HttpCode,
12+
HttpStatus,
13+
UseGuards,
1114
} from '@nestjs/common';
1215
import {
1316
ApiTags,
@@ -20,13 +23,17 @@ import { DonationsService } from './providers/donations.service';
2023
import { CreateDonationDto } from './dto/create-donation.dto';
2124
import { UpdateDonationDto } from './dto/update-donation.dto';
2225
import { DonationResponseDto } from './dto/donation-response.dto';
26+
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
27+
import { CurrentUser } from '../common/decorators/current-user.decorator';
28+
import { Public } from '../common/decorators/public.decorator';
2329

2430
@ApiTags('Donations')
2531
@Controller('donations')
2632
export class DonationsController {
2733
constructor(private readonly donationsService: DonationsService) {}
2834

2935
@Post()
36+
@UseGuards(JwtAuthGuard)
3037
@ApiBearerAuth()
3138
@ApiOperation({ summary: 'Create a new donation' })
3239
@ApiResponse({ status: 201, type: DonationResponseDto })
@@ -40,18 +47,20 @@ export class DonationsController {
4047
}
4148

4249
@Get()
50+
@Public()
4351
@ApiOperation({ summary: 'Get all donations with pagination' })
4452
@ApiResponse({ status: 200, description: 'List of donations' })
4553
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
4654
@ApiQuery({ name: 'limit', required: false, type: Number, example: 10 })
4755
findAll(
48-
@Query('page', new ParseUUIDPipe({ optional: true })) page: number = 1,
49-
@Query('limit', new ParseUUIDPipe({ optional: true })) limit: number = 10,
56+
@Query('page') page: number = 1,
57+
@Query('limit') limit: number = 10,
5058
) {
5159
return this.donationsService.findAll(page, limit);
5260
}
5361

5462
@Get(':id')
63+
@Public()
5564
@ApiOperation({ summary: 'Get donation by ID' })
5665
@ApiResponse({ status: 200, type: DonationResponseDto })
5766
@ApiResponse({ status: 404, description: 'Donation not found' })
@@ -60,6 +69,7 @@ export class DonationsController {
6069
}
6170

6271
@Get('project/:projectId')
72+
@Public()
6373
@ApiOperation({ summary: 'Get donations for a specific project' })
6474
@ApiResponse({ status: 200, description: 'List of project donations' })
6575
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
@@ -86,6 +96,7 @@ export class DonationsController {
8696
}
8797

8898
@Get('transaction/:hash')
99+
@Public()
89100
@ApiOperation({ summary: 'Get donation by transaction hash' })
90101
@ApiResponse({ status: 200, type: DonationResponseDto })
91102
@ApiResponse({ status: 404, description: 'Donation not found' })
@@ -94,6 +105,7 @@ export class DonationsController {
94105
}
95106

96107
@Patch(':id')
108+
@UseGuards(JwtAuthGuard)
97109
@ApiBearerAuth()
98110
@ApiOperation({ summary: 'Update a donation' })
99111
@ApiResponse({ status: 200, type: DonationResponseDto })
@@ -106,6 +118,7 @@ export class DonationsController {
106118
}
107119

108120
@Delete(':id')
121+
@UseGuards(JwtAuthGuard)
109122
@ApiBearerAuth()
110123
@ApiOperation({ summary: 'Delete a donation' })
111124
@ApiResponse({ status: 200, description: 'Donation deleted successfully' })
@@ -115,16 +128,19 @@ export class DonationsController {
115128
}
116129

117130
@Get('analytics/project/:projectId/total')
131+
@Public()
118132
@ApiOperation({ summary: 'Get total donations amount for a project' })
119133
@ApiResponse({ status: 200, description: 'Total donations amount' })
120134
getTotalForProject(@Param('projectId', ParseUUIDPipe) projectId: string) {
121135
return this.donationsService.getTotalDonationsForProject(projectId);
122136
}
123137

124138
@Get('analytics/project/:projectId/count')
139+
@Public()
125140
@ApiOperation({ summary: 'Get donation count for a project' })
126141
@ApiResponse({ status: 200, description: 'Donation count' })
127142
getCountForProject(@Param('projectId', ParseUUIDPipe) projectId: string) {
128143
return this.donationsService.getDonationCountForProject(projectId);
129144
}
130145
}
146+

src/donations/donations.module.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,17 @@ import { DonationsService } from './providers/donations.service';
55
import { Donation } from './entities/donation.entity';
66
import { Project } from '../projects/entities/project.entity';
77
import { User } from '../users/entities/user.entity';
8+
import { StellarBlockchainService } from '../common/services/stellar-blockchain.service';
9+
import { ProjectsService } from '../projects/providers/projects.service';
10+
import { ProjectHistory } from '../projects/entities/project-history.entity';
11+
import { MailService } from '../mail/mail.service';
812

913
@Module({
10-
imports: [TypeOrmModule.forFeature([Donation, Project, User])],
14+
imports: [
15+
TypeOrmModule.forFeature([Donation, Project, User, ProjectHistory]),
16+
],
1117
controllers: [DonationsController],
12-
providers: [DonationsService],
18+
providers: [DonationsService, StellarBlockchainService, ProjectsService, MailService],
1319
exports: [DonationsService, TypeOrmModule],
1420
})
1521
export class DonationsModule {}

src/donations/dto/create-donation.dto.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,4 @@
1-
import {
2-
IsNotEmpty,
3-
IsNumber,
4-
IsString,
5-
IsOptional,
6-
Min,
7-
} from 'class-validator';
1+
import { IsNotEmpty, IsNumber, IsString, IsOptional, Min, Matches } from 'class-validator';
82
import { ApiProperty } from '@nestjs/swagger';
93

104
export class CreateDonationDto {
@@ -31,12 +25,12 @@ export class CreateDonationDto {
3125
@IsString()
3226
assetType?: string;
3327

34-
@ApiProperty({
35-
example: 'transaction-hash-xyz',
36-
description: 'Blockchain transaction hash',
37-
})
28+
@ApiProperty({ example: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', description: 'Stellar blockchain transaction hash (64 hex characters)' })
3829
@IsNotEmpty()
3930
@IsString()
31+
@Matches(/^[a-f0-9]{64}$/, {
32+
message: 'Transaction hash must be a valid Stellar transaction hash (64 hexadecimal characters)',
33+
})
4034
transactionHash: string;
4135

4236
@ApiProperty({

0 commit comments

Comments
 (0)