Skip to content

Commit 2f1b964

Browse files
authored
Merge pull request #624 from scriptnovaa/feat/stellar-wave-532-535
Pagination envelope, memo validation, webhook DLQ, Horizon network URLs
2 parents 4a8b799 + 93e81ab commit 2f1b964

14 files changed

Lines changed: 446 additions & 1 deletion

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
# Never commit .env to version control.
55
# ============================================================
66

7+
# Stellar Configuration
8+
STELLAR_HORIZON_TESTNET_URL=https://horizon-testnet.stellar.org
9+
STELLAR_HORIZON_MAINNET_URL=https://horizon.stellar.org
710
# ------------------------------------------------------------
811
# Database
912
# Required: PostgreSQL connection string (Prisma)

src/balance-indexer/balance-indexer.service.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import { PrismaService } from '../prisma/prisma.service';
99
import { StellarHorizonService } from './stellar-horizon.service';
1010
import { ConfigService } from '@nestjs/config';
11+
import { WalletNetwork } from '../wallets/domain/wallet.model';
1112
import { WebhookEventEmitterService } from '../webhooks/webhook-event-emitter.service';
1213
import { BalanceRepository } from './balance.repository';
1314
import { BalanceCacheService } from './balance-cache.service';
@@ -413,8 +414,10 @@ export class BalanceIndexerService implements OnModuleInit, OnModuleDestroy {
413414
throw new NotFoundException(`Wallet ${walletId} not found`);
414415
}
415416

417+
// Check if account exists on-chain (on the wallet's own network)
416418
const accountExists = await this.stellarHorizonService.accountExists(
417419
wallet.publicKey,
420+
wallet.network as WalletNetwork,
418421
);
419422

420423
if (!accountExists) {
@@ -444,6 +447,11 @@ export class BalanceIndexerService implements OnModuleInit, OnModuleDestroy {
444447
return result;
445448
}
446449

450+
// Fetch balances from Horizon
451+
const horizonBalances = await this.stellarHorizonService.getAccountBalances(
452+
wallet.publicKey,
453+
wallet.network as WalletNetwork,
454+
);
447455
const horizonBalances =
448456
await this.stellarHorizonService.getAccountBalances(wallet.publicKey);
449457

@@ -559,6 +567,7 @@ export class BalanceIndexerService implements OnModuleInit, OnModuleDestroy {
559567

560568
const horizonBalances = await this.stellarHorizonService.getAccountBalances(
561569
wallet.publicKey,
570+
wallet.network as WalletNetwork,
562571
);
563572
const onChainBalance = horizonBalances.find((b) =>
564573
this.assetsMatch(b.asset, asset),

src/balance-indexer/stellar-horizon.service.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ import {
66
import { ConfigService } from '@nestjs/config';
77
import { Server } from 'stellar-sdk';
88
import { Asset, AssetType, BalanceUpdate } from './domain/balance.model';
9+
import { WalletNetwork } from '../wallets/domain/wallet.model';
10+
11+
export interface HorizonAccountResponse {
12+
id: string;
13+
sequence: string;
14+
balances: HorizonBalance[];
15+
}
916
import { RequestContextService } from '../common/request-context/request-context.service';
1017
import {
1118
CircuitBreaker,
@@ -22,6 +29,22 @@ export interface HorizonBalance {
2229
@Injectable()
2330
export class StellarHorizonService {
2431
private readonly logger = new Logger(StellarHorizonService.name);
32+
private readonly horizonUrls: Record<WalletNetwork, string>;
33+
34+
constructor(private readonly configService: ConfigService) {
35+
this.horizonUrls = {
36+
[WalletNetwork.TESTNET]: this.configService.get<string>(
37+
'STELLAR_HORIZON_TESTNET_URL',
38+
'https://horizon-testnet.stellar.org',
39+
),
40+
[WalletNetwork.MAINNET]: this.configService.get<string>(
41+
'STELLAR_HORIZON_MAINNET_URL',
42+
'https://horizon.stellar.org',
43+
),
44+
};
45+
46+
this.logger.log(
47+
`Initialized Stellar Horizon clients: testnet=${this.horizonUrls[WalletNetwork.TESTNET]}, mainnet=${this.horizonUrls[WalletNetwork.MAINNET]}`,
2548
private readonly horizonUrl: string;
2649
private readonly maxRetries: number;
2750
private readonly retryBackoffMs: number;
@@ -37,7 +60,14 @@ export class StellarHorizonService {
3760
'STELLAR_HORIZON_URL',
3861
'https://horizon-testnet.stellar.org',
3962
);
63+
}
4064

65+
/**
66+
* Resolves the Horizon base URL for a given network. Defaults to testnet
67+
* when no network is specified, matching prior (single-URL) behavior.
68+
*/
69+
private resolveUrl(network: WalletNetwork = WalletNetwork.TESTNET): string {
70+
return this.horizonUrls[network];
4171
this.maxRetries = this.configService.get<number>(
4272
'STELLAR_HORIZON_MAX_RETRIES',
4373
3,
@@ -120,6 +150,11 @@ export class StellarHorizonService {
120150
/**
121151
* Fetches account balances from Stellar Horizon
122152
*/
153+
async getAccountBalances(
154+
publicKey: string,
155+
network: WalletNetwork = WalletNetwork.TESTNET,
156+
): Promise<BalanceUpdate[]> {
157+
const horizonUrl = this.resolveUrl(network);
123158
async getAccountBalances(publicKey: string): Promise<BalanceUpdate[]> {
124159
const requestId = this.requestContext.getRequestId();
125160
const logPrefix = requestId ? `[${requestId}] ` : '';
@@ -130,6 +165,7 @@ export class StellarHorizonService {
130165
);
131166

132167
// Simplified mock implementation
168+
const response = await this.mockHorizonRequest(publicKey, horizonUrl);
133169
const response = await this.withRetry(
134170
() => this.mockHorizonRequest(publicKey),
135171
`getAccountBalances(${publicKey.substring(0, 8)}...)`,
@@ -162,6 +198,12 @@ export class StellarHorizonService {
162198
/**
163199
* Checks if an account exists on-chain
164200
*/
201+
async accountExists(
202+
publicKey: string,
203+
network: WalletNetwork = WalletNetwork.TESTNET,
204+
): Promise<boolean> {
205+
try {
206+
await this.mockHorizonRequest(publicKey, this.resolveUrl(network));
165207
async accountExists(publicKey: string): Promise<boolean> {
166208
const requestId = this.requestContext.getRequestId();
167209
const logPrefix = requestId ? `[${requestId}] ` : '';
@@ -270,4 +312,36 @@ export class StellarHorizonService {
270312
throw new Error(`Unknown asset type: ${horizonBalance.asset_type}`);
271313
}
272314
}
315+
316+
/**
317+
* Mock Horizon request (replace with real stellar-sdk in production)
318+
*/
319+
private async mockHorizonRequest(
320+
publicKey: string,
321+
horizonUrl: string,
322+
): Promise<HorizonAccountResponse> {
323+
this.logger.debug(`Requesting account ${publicKey} from ${horizonUrl}`);
324+
325+
// Simulate API call delay
326+
await new Promise((resolve) => setTimeout(resolve, 100));
327+
328+
// Mock response with realistic data
329+
return {
330+
id: publicKey,
331+
sequence: '123456789',
332+
balances: [
333+
{
334+
asset_type: 'native',
335+
balance: '1000.5000000',
336+
},
337+
{
338+
asset_type: 'credit_alphanum4',
339+
asset_code: 'USDC',
340+
asset_issuer:
341+
'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN',
342+
balance: '500.0000000',
343+
},
344+
],
345+
};
346+
}
273347
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { BadRequestException } from '@nestjs/common';
2+
3+
export interface PaginationQuery {
4+
page?: string;
5+
limit?: string;
6+
}
7+
8+
export interface PaginationParams {
9+
page: number;
10+
limit: number;
11+
skip: number;
12+
}
13+
14+
export interface PaginationMeta {
15+
page: number;
16+
limit: number;
17+
total: number;
18+
totalPages: number;
19+
}
20+
21+
export interface PaginatedResult<T> {
22+
data: T[];
23+
meta: PaginationMeta;
24+
}
25+
26+
export const DEFAULT_PAGE = 1;
27+
export const DEFAULT_PAGE_SIZE = 20;
28+
export const MAX_PAGE_SIZE = 100;
29+
30+
/**
31+
* Shared pagination envelope for list APIs (wallet/payment/transaction/custody flows).
32+
* Validates page/limit query params consistently instead of each module rolling its own.
33+
*/
34+
export function parsePagination(query: PaginationQuery): PaginationParams {
35+
const page =
36+
query.page !== undefined ? Number(query.page) : DEFAULT_PAGE;
37+
const limit =
38+
query.limit !== undefined ? Number(query.limit) : DEFAULT_PAGE_SIZE;
39+
40+
if (!Number.isInteger(page) || page < 1) {
41+
throw new BadRequestException('page must be a positive integer');
42+
}
43+
44+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_PAGE_SIZE) {
45+
throw new BadRequestException(
46+
`limit must be an integer between 1 and ${MAX_PAGE_SIZE}`,
47+
);
48+
}
49+
50+
return { page, limit, skip: (page - 1) * limit };
51+
}
52+
53+
export function buildPaginatedResponse<T>(
54+
data: T[],
55+
total: number,
56+
page: number,
57+
limit: number,
58+
): PaginatedResult<T> {
59+
return {
60+
data,
61+
meta: {
62+
page,
63+
limit,
64+
total,
65+
totalPages: limit > 0 ? Math.ceil(total / limit) : 0,
66+
},
67+
};
68+
}

src/common/stellar/memo.util.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { BadRequestException } from '@nestjs/common';
2+
3+
export enum MemoType {
4+
NONE = 'MEMO_NONE',
5+
TEXT = 'MEMO_TEXT',
6+
ID = 'MEMO_ID',
7+
HASH = 'MEMO_HASH',
8+
RETURN = 'MEMO_RETURN',
9+
}
10+
11+
export interface MemoInput {
12+
type: MemoType;
13+
value?: string;
14+
}
15+
16+
const MEMO_TEXT_MAX_BYTES = 28;
17+
const MEMO_ID_MAX = BigInt('18446744073709551615'); // uint64 max
18+
const MEMO_HASH_HEX_LENGTH = 64; // 32 bytes, hex-encoded
19+
20+
/**
21+
* Validates a Stellar transaction memo against the protocol's per-type constraints:
22+
* https://developers.stellar.org/docs/encyclopedia/memos
23+
*/
24+
export function validateMemo(memo?: MemoInput): void {
25+
if (!memo || memo.type === MemoType.NONE) {
26+
return;
27+
}
28+
29+
const { type, value } = memo;
30+
31+
if (value === undefined || value === '') {
32+
throw new BadRequestException(`memo.value is required for ${type}`);
33+
}
34+
35+
switch (type) {
36+
case MemoType.TEXT: {
37+
const byteLength = Buffer.byteLength(value, 'utf8');
38+
if (byteLength > MEMO_TEXT_MAX_BYTES) {
39+
throw new BadRequestException(
40+
`memo of type MEMO_TEXT must be at most ${MEMO_TEXT_MAX_BYTES} bytes, got ${byteLength}`,
41+
);
42+
}
43+
break;
44+
}
45+
46+
case MemoType.ID: {
47+
if (!/^\d+$/.test(value)) {
48+
throw new BadRequestException(
49+
'memo of type MEMO_ID must be an unsigned integer string',
50+
);
51+
}
52+
if (BigInt(value) > MEMO_ID_MAX) {
53+
throw new BadRequestException(
54+
`memo of type MEMO_ID must not exceed ${MEMO_ID_MAX.toString()}`,
55+
);
56+
}
57+
break;
58+
}
59+
60+
case MemoType.HASH:
61+
case MemoType.RETURN: {
62+
if (!/^[0-9a-fA-F]+$/.test(value) || value.length !== MEMO_HASH_HEX_LENGTH) {
63+
throw new BadRequestException(
64+
`memo of type ${type} must be a ${MEMO_HASH_HEX_LENGTH}-character hex string (32 bytes)`,
65+
);
66+
}
67+
break;
68+
}
69+
70+
default:
71+
throw new BadRequestException(`Unsupported memo type: ${type}`);
72+
}
73+
}

src/payments/payments.controller.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
Patch,
77
Param,
88
Delete,
9+
Query,
910
UseGuards,
1011
Query,
1112
} from '@nestjs/common';
@@ -18,6 +19,7 @@ import {
1819
ApiQuery,
1920
} from '@nestjs/swagger';
2021
import { PaymentsService } from './payments.service';
22+
import { PaginationQuery } from '../common/pagination/pagination.util';
2123
import { CreatePaymentDto } from './dto/create-payment.dto';
2224
import { UpdatePaymentDto } from './dto/update-payment.dto';
2325
import { PaymentsFilterDto } from './dto/payments-filter.dto';
@@ -172,6 +174,8 @@ export class PaymentsController {
172174
},
173175
})
174176
@Get()
177+
findAll(@Query() query: PaginationQuery) {
178+
return this.paymentsService.findAll(query);
175179
findAll(
176180
@Query() pagination: PaginationDto,
177181
@Query() filters: PaymentsFilterDto,

src/payments/payments.service.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
99
import { CreatePaymentDto } from './dto/create-payment.dto';
1010
import { UpdatePaymentDto } from './dto/update-payment.dto';
1111
import { PrismaService } from '../prisma/prisma.service';
12+
import { LimitsService } from '../limits/limits.service';
13+
import {
14+
PaginationQuery,
15+
parsePagination,
16+
buildPaginatedResponse,
17+
} from '../common/pagination/pagination.util';
1218
import { WalletsService } from '../wallets/wallets.service';
1319
import {
1420
PAYMENT_LIMITS_PORT,
@@ -128,6 +134,19 @@ export class PaymentsService {
128134
return payment;
129135
}
130136

137+
async findAll(query: PaginationQuery = {}) {
138+
const { page, limit, skip } = parsePagination(query);
139+
140+
const [data, total] = await Promise.all([
141+
this.prisma.payment.findMany({
142+
skip,
143+
take: limit,
144+
orderBy: { createdAt: 'desc' },
145+
}),
146+
this.prisma.payment.count(),
147+
]);
148+
149+
return buildPaginatedResponse(data, total, page, limit);
131150
async findAll(
132151
pagination: PaginationDto,
133152
filters: PaymentsFilterDto,

src/transactions/dto/create-transaction.dto.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { MemoType } from '../../common/stellar/memo.util';
12
import {
23
IsEnum,
34
IsNotEmpty,
@@ -39,6 +40,11 @@ export class TransactionAssetDto {
3940
issuer?: string;
4041
}
4142

43+
export class TransactionMemoDto {
44+
type: MemoType;
45+
value?: string;
46+
}
47+
4248
export class CreateTransactionDto {
4349
/** Positive decimal string, up to 7 decimal places (Stellar precision) */
4450
@IsString()
@@ -57,6 +63,7 @@ export class CreateTransactionDto {
5763
@IsOptional()
5864
@IsUUID()
5965
receiverWalletId?: string;
66+
memo?: TransactionMemoDto;
6067

6168
/** Optional memo — max 28 bytes (Stellar text memo limit) */
6269
@IsOptional()

0 commit comments

Comments
 (0)