From 1de97d3f787acc658269fcbc8549639c2afc3c3c Mon Sep 17 00:00:00 2001 From: Justice Date: Thu, 28 May 2026 21:56:44 +0100 Subject: [PATCH] fix: validate Stellar public keys at payment boundaries --- backend/src/api/routes/sep24.route.test.ts | 68 ++++++-- backend/src/api/routes/sep24.route.ts | 20 ++- .../services/batch-payment.service.test.ts | 149 ++++++++++++++---- backend/src/services/batch-payment.service.ts | 46 ++++-- .../services/recurring-payments.service.ts | 4 +- .../src/services/sequence-number.service.ts | 15 +- backend/src/utils/stellar-address.test.ts | 28 ++++ backend/src/utils/stellar-address.ts | 23 +++ backend/tsconfig.json | 3 +- 9 files changed, 290 insertions(+), 66 deletions(-) create mode 100644 backend/src/utils/stellar-address.test.ts create mode 100644 backend/src/utils/stellar-address.ts diff --git a/backend/src/api/routes/sep24.route.test.ts b/backend/src/api/routes/sep24.route.test.ts index df5d132..157b934 100644 --- a/backend/src/api/routes/sep24.route.test.ts +++ b/backend/src/api/routes/sep24.route.test.ts @@ -9,6 +9,15 @@ jest.mock('crypto', () => { }; }); +jest.mock('../../lib/prisma', () => ({ + __esModule: true, + default: { + quote: { + findUnique: jest.fn(), + }, + }, +})); + import sep24Router from './sep24.route'; jest.setTimeout(15000); @@ -19,6 +28,7 @@ describe('SEP-24 Routes', () => { app.use('/', sep24Router); const baseUrl = 'http://localhost:4100'; + const validAccount = 'GCM5WPR4DDR24FSAX5LIEM4J7AI3KOWJYANSXEPKYXCSZOTAYXE75AFN'; beforeEach(() => { process.env.INTERACTIVE_URL = baseUrl; @@ -32,7 +42,7 @@ describe('SEP-24 Routes', () => { it('returns 400 when asset_code is missing', async () => { const res = await request(app) .post('/transactions/deposit/interactive') - .send({ account: 'GACCOUNT' }); + .send({ account: validAccount }); expect(res.statusCode).toBe(400); expect(res.body.error).toBe('asset_code is required'); @@ -45,7 +55,7 @@ describe('SEP-24 Routes', () => { expect(res.statusCode).toBe(400); expect(res.body.error).toContain('Asset DOGE is not supported'); - expect(res.body.error).toContain('Supported assets: USDC, USD, BTC, ETH'); + expect(res.body.error).toContain('Supported assets: USDC, USD'); }); it('returns an interactive URL for supported assets (with optional params)', async () => { @@ -53,7 +63,7 @@ describe('SEP-24 Routes', () => { .post('/transactions/deposit/interactive') .send({ asset_code: 'usdc', - account: 'GACCOUNT', + account: validAccount, amount: '12.50', lang: 'fr' }); @@ -66,11 +76,30 @@ describe('SEP-24 Routes', () => { expect(parsed.pathname).toBe('/kyc-deposit'); expect(parsed.searchParams.get('transaction_id')).toBe(res.body.id); expect(parsed.searchParams.get('asset_code')).toBe('USDC'); - expect(parsed.searchParams.get('account')).toBe('GACCOUNT'); + expect(parsed.searchParams.get('account')).toBe(validAccount); expect(parsed.searchParams.get('amount')).toBe('12.50'); expect(parsed.searchParams.get('lang')).toBe('fr'); }); + it.each([ + ['plain invalid string', 'INVALID_ADDRESS'], + ['secret seed-like value', `S${validAccount.slice(1)}`], + ['padded public key', `${validAccount} `], + ['checksum mismatch', `${validAccount.slice(0, -1)}A`], + ['contract address-like value', `C${validAccount.slice(1)}`], + ])('returns 400 when account is %s', async (_caseName, account) => { + const res = await request(app) + .post('/transactions/deposit/interactive') + .send({ + asset_code: 'USDC', + account, + }); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe('account must be a valid Stellar public key'); + expect(res.body.error).not.toContain(account); + }); + it('defaults lang to en when omitted', async () => { const res = await request(app) .post('/transactions/deposit/interactive') @@ -87,7 +116,7 @@ describe('SEP-24 Routes', () => { it('returns 400 when asset_code is missing', async () => { const res = await request(app) .post('/transactions/withdraw/interactive') - .send({ account: 'GACCOUNT' }); + .send({ account: validAccount }); expect(res.statusCode).toBe(400); expect(res.body.error).toBe('asset_code is required'); @@ -100,25 +129,42 @@ describe('SEP-24 Routes', () => { expect(res.statusCode).toBe(400); expect(res.body.error).toContain('Asset DOGE is not supported'); - expect(res.body.error).toContain('Supported assets: USDC, USD, BTC, ETH'); + expect(res.body.error).toContain('Supported assets: USDC, USD'); }); it('returns an interactive URL for supported assets', async () => { const res = await request(app) .post('/transactions/withdraw/interactive') .send({ - asset_code: 'BTC', - account: 'GACCOUNT', + asset_code: 'USD', + account: validAccount, amount: '1' }); expect(res.statusCode).toBe(200); const parsed = new URL(res.body.url); expect(parsed.pathname).toBe('/kyc-withdraw'); - expect(parsed.searchParams.get('asset_code')).toBe('BTC'); - expect(parsed.searchParams.get('account')).toBe('GACCOUNT'); + expect(parsed.searchParams.get('asset_code')).toBe('USD'); + expect(parsed.searchParams.get('account')).toBe(validAccount); expect(parsed.searchParams.get('amount')).toBe('1'); }); + + it.each([ + ['plain invalid string', 'INVALID_ADDRESS'], + ['secret seed-like value', `S${validAccount.slice(1)}`], + ['padded public key', ` ${validAccount}`], + ['muxed account-like value', `M${validAccount.slice(1)}`], + ])('returns 400 when account is %s', async (_caseName, account) => { + const res = await request(app) + .post('/transactions/withdraw/interactive') + .send({ + asset_code: 'USD', + account, + }); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe('account must be a valid Stellar public key'); + expect(res.body.error).not.toContain(account); + }); }); }); - diff --git a/backend/src/api/routes/sep24.route.ts b/backend/src/api/routes/sep24.route.ts index 24a3157..b96fe36 100644 --- a/backend/src/api/routes/sep24.route.ts +++ b/backend/src/api/routes/sep24.route.ts @@ -8,6 +8,7 @@ import { SUPPORTED_ASSETS, } from '../../services/kyc.service'; import prisma from '../../lib/prisma'; +import { isValidStellarPublicKey } from '../../utils/stellar-address'; const router = Router(); @@ -29,8 +30,15 @@ const unsupportedAssetResponse = (assetCode: string) => ({ error: `Asset ${assetCode} is not supported. Supported assets: ${SUPPORTED_ASSETS.join(', ')}`, }); +const invalidAccountResponse = () => ({ + error: 'account must be a valid Stellar public key', +}); + const getBaseInteractiveUrl = (): string => process.env.INTERACTIVE_URL || 'http://localhost:3000'; +const hasInvalidAccount = (account: unknown): boolean => + account !== undefined && !isValidStellarPublicKey(account); + /** * @swagger * /sep24/transactions/deposit/interactive: @@ -53,7 +61,7 @@ const getBaseInteractiveUrl = (): string => process.env.INTERACTIVE_URL || 'http * example: USDC * account: * type: string - * description: Stellar account address + * description: Stellar Ed25519 public key (G...) * amount: * type: string * description: Amount to deposit @@ -95,6 +103,10 @@ router.post('/transactions/deposit/interactive', async (req: Request, res: Respo return res.status(400).json(unsupportedAssetResponse(asset_code)); } + if (hasInvalidAccount(account)) { + return res.status(400).json(invalidAccountResponse()); + } + if (quote_id) { const quote = await prisma.quote.findUnique({ where: { id: quote_id } }); if (!quote) { @@ -144,7 +156,7 @@ router.post('/transactions/deposit/interactive', async (req: Request, res: Respo * example: USDC * account: * type: string - * description: Destination Stellar account address + * description: Destination Stellar Ed25519 public key (G...) * amount: * type: string * description: Amount to withdraw @@ -186,6 +198,10 @@ router.post('/transactions/withdraw/interactive', async (req: Request, res: Resp return res.status(400).json(unsupportedAssetResponse(asset_code)); } + if (hasInvalidAccount(account)) { + return res.status(400).json(invalidAccountResponse()); + } + if (quote_id) { const quote = await prisma.quote.findUnique({ where: { id: quote_id } }); if (!quote) { diff --git a/backend/src/services/batch-payment.service.test.ts b/backend/src/services/batch-payment.service.test.ts index b8d2d5a..c96d316 100644 --- a/backend/src/services/batch-payment.service.test.ts +++ b/backend/src/services/batch-payment.service.test.ts @@ -6,9 +6,28 @@ import { BatchPaymentService } from '../services/batch-payment.service'; import { BatchPaymentError, BatchErrorType, PaymentOperation } from '../services/batch-payment.types'; +import { Horizon } from '@stellar/stellar-sdk'; + +// Mock key management because these tests exercise validation and transaction assembly paths. +jest.mock('../lib/key-management.service', () => ({ + getKeyManagementService: jest.fn(() => ({ + decryptKey: jest.fn(), + getKeyByReference: jest.fn(), + })), +})); + +jest.mock('../lib/key-management.types', () => ({ + KeyManagementError: class KeyManagementError extends Error {}, +})); // Mock the Stellar SDK jest.mock('@stellar/stellar-sdk', () => { + const mockValidPublicKeys = new Set([ + 'GCM5WPR4DDR24FSAX5LIEM4J7AI3KOWJYANSXEPKYXCSZOTAYXE75AFN', + 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + ]); + const mockAccount = { sequenceNumber: () => '123456789012345678', }; @@ -18,13 +37,22 @@ jest.mock('@stellar/stellar-sdk', () => { submitTransaction: jest.fn(), }; + const mockAsset = Object.assign( + jest.fn().mockImplementation((code, issuer) => ({ code, issuer })), + { + native: jest.fn().mockReturnValue({ code: 'XLM' }), + } + ); + return { Keypair: { fromSecret: jest.fn().mockReturnValue({ - publicKey: () => 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + publicKey: () => 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', }), }, - Server: jest.fn().mockImplementation(() => mockServer), + Horizon: { + Server: jest.fn().mockImplementation(() => mockServer), + }, TransactionBuilder: jest.fn().mockImplementation(() => ({ addOperation: jest.fn().mockReturnThis(), build: jest.fn().mockReturnValue({ @@ -39,11 +67,9 @@ jest.mock('@stellar/stellar-sdk', () => { Operation: { payment: jest.fn().mockReturnValue({ type: 'payment' }), }, - Asset: { - native: jest.fn().mockReturnValue({ code: 'XLM' }), - }, + Asset: mockAsset, StrKey: { - isValidEd25519PublicKey: jest.fn((key) => key.startsWith('G') && key.length === 56), + isValidEd25519PublicKey: jest.fn((key) => mockValidPublicKeys.has(key)), }, Account: jest.fn(), }; @@ -70,14 +96,22 @@ jest.mock('../utils/logger', () => ({ describe('BatchPaymentService', () => { let batchService: BatchPaymentService; + const validDestination = 'GCM5WPR4DDR24FSAX5LIEM4J7AI3KOWJYANSXEPKYXCSZOTAYXE75AFN'; + const validAssetIssuer = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; + const getMockServerInstance = (): { + submitTransaction: jest.Mock; + } => + ((Horizon.Server as unknown as jest.Mock).mock.results[0]?.value ?? {}) as { + submitTransaction: jest.Mock; + }; const mockPayments: PaymentOperation[] = [ { - destination: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + destination: validDestination, amount: '10.5', }, { - destination: 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC', + destination: validAssetIssuer, amount: '20.0', }, ]; @@ -97,8 +131,7 @@ describe('BatchPaymentService', () => { describe('executeBatch', () => { it('should successfully execute a batch of payments', async () => { // Mock successful transaction submission - const { Server } = require('@stellar/stellar-sdk'); - const mockServerInstance = Server.mock.results[0]?.value || {}; + const mockServerInstance = getMockServerInstance(); mockServerInstance.submitTransaction = jest.fn().mockResolvedValue({ hash: 'mock_tx_hash', feeCharged: '200', @@ -118,8 +151,8 @@ describe('BatchPaymentService', () => { }); it('should reject batch exceeding maximum operations', async () => { - const tooManyPayments: PaymentOperation[] = Array.from({ length: 101 }, (_, i) => ({ - destination: `GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB${i}`, + const tooManyPayments: PaymentOperation[] = Array.from({ length: 101 }, () => ({ + destination: validDestination, amount: '1.0', })); @@ -167,10 +200,53 @@ describe('BatchPaymentService', () => { }); }); + it.each([ + ['secret seed-like value', `S${validDestination.slice(1)}`], + ['padded public key', `${validDestination} `], + ['lowercase public key', validDestination.toLowerCase()], + ['checksum mismatch', `${validDestination.slice(0, -1)}A`], + ['muxed account-like value', `M${validDestination.slice(1)}`], + ])('should reject destination edge case: %s', async (_caseName, destination) => { + await expect( + batchService.executeBatch({ + payments: [ + { + destination, + amount: '10.0', + }, + ], + sourceSecretKey: mockSecretKey, + }) + ).rejects.toMatchObject({ + type: BatchErrorType.INVALID_ADDRESS, + message: 'Invalid destination Stellar address at index 0', + }); + }); + + it('should not echo invalid destination values in errors', async () => { + const invalidDestination = `S${validDestination.slice(1)}`; + + try { + await batchService.executeBatch({ + payments: [ + { + destination: invalidDestination, + amount: '10.0', + }, + ], + sourceSecretKey: mockSecretKey, + }); + throw new Error('Expected invalid destination to be rejected'); + } catch (error) { + expect(error).toBeInstanceOf(BatchPaymentError); + expect((error as Error).message).not.toContain(invalidDestination); + } + }); + it('should validate payment amounts', async () => { const invalidPayments: PaymentOperation[] = [ { - destination: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + destination: validDestination, amount: '0', }, ]; @@ -188,14 +264,13 @@ describe('BatchPaymentService', () => { it('should handle native XLM payments', async () => { const xlmPayments: PaymentOperation[] = [ { - destination: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + destination: validDestination, amount: '10.0', assetCode: 'XLM', }, ]; - const { Server } = require('@stellar/stellar-sdk'); - const mockServerInstance = Server.mock.results[0]?.value || {}; + const mockServerInstance = getMockServerInstance(); mockServerInstance.submitTransaction = jest.fn().mockResolvedValue({ hash: 'mock_xlm_hash', feeCharged: '100', @@ -213,15 +288,14 @@ describe('BatchPaymentService', () => { it('should handle custom asset payments', async () => { const customAssetPayments: PaymentOperation[] = [ { - destination: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + destination: validDestination, amount: '100.0', assetCode: 'USDC', - assetIssuer: 'GDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD', + assetIssuer: validAssetIssuer, }, ]; - const { Server } = require('@stellar/stellar-sdk'); - const mockServerInstance = Server.mock.results[0]?.value || {}; + const mockServerInstance = getMockServerInstance(); mockServerInstance.submitTransaction = jest.fn().mockResolvedValue({ hash: 'mock_usdc_hash', feeCharged: '100', @@ -237,8 +311,7 @@ describe('BatchPaymentService', () => { }); it('should retry on sequence number conflicts', async () => { - const { Server } = require('@stellar/stellar-sdk'); - const mockServerInstance = Server.mock.results[0]?.value || {}; + const mockServerInstance = getMockServerInstance(); // Fail first attempt, succeed on second mockServerInstance.submitTransaction = jest @@ -260,8 +333,7 @@ describe('BatchPaymentService', () => { }); it('should fail after max retries', async () => { - const { Server } = require('@stellar/stellar-sdk'); - const mockServerInstance = Server.mock.results[0]?.value || {}; + const mockServerInstance = getMockServerInstance(); mockServerInstance.submitTransaction = jest .fn() @@ -278,13 +350,12 @@ describe('BatchPaymentService', () => { describe('executeBatchInChunks', () => { it('should split large payment list into chunks', async () => { - const largePaymentList: PaymentOperation[] = Array.from({ length: 250 }, (_, i) => ({ - destination: `GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB${i % 10}`, + const largePaymentList: PaymentOperation[] = Array.from({ length: 250 }, () => ({ + destination: validDestination, amount: '1.0', })); - const { Server } = require('@stellar/stellar-sdk'); - const mockServerInstance = Server.mock.results[0]?.value || {}; + const mockServerInstance = getMockServerInstance(); mockServerInstance.submitTransaction = jest.fn().mockResolvedValue({ hash: 'mock_chunk_hash', feeCharged: '100', @@ -306,8 +377,7 @@ describe('BatchPaymentService', () => { describe('handlePartialFailure', () => { it('should retry failed payments successfully', async () => { - const { Server } = require('@stellar/stellar-sdk'); - const mockServerInstance = Server.mock.results[0]?.value || {}; + const mockServerInstance = getMockServerInstance(); mockServerInstance.submitTransaction = jest.fn().mockResolvedValue({ hash: 'mock_retry_success', feeCharged: '100', @@ -325,8 +395,7 @@ describe('BatchPaymentService', () => { }); it('should handle retry failure', async () => { - const { Server } = require('@stellar/stellar-sdk'); - const mockServerInstance = Server.mock.results[0]?.value || {}; + const mockServerInstance = getMockServerInstance(); mockServerInstance.submitTransaction = jest .fn() .mockRejectedValue(new Error('Retry failed')); @@ -351,12 +420,13 @@ describe('BatchPaymentService', () => { describe('validatePayments', () => { it('should reject invalid asset issuer', async () => { + const invalidAssetIssuer = `S${validAssetIssuer.slice(1)}`; const invalidAssetPayments: PaymentOperation[] = [ { - destination: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + destination: validDestination, amount: '10.0', assetCode: 'USDC', - assetIssuer: 'INVALID_ISSUER', + assetIssuer: invalidAssetIssuer, }, ]; @@ -368,6 +438,17 @@ describe('BatchPaymentService', () => { ).rejects.toMatchObject({ type: BatchErrorType.INVALID_ASSET, }); + + try { + await batchService.executeBatch({ + payments: invalidAssetPayments, + sourceSecretKey: mockSecretKey, + }); + throw new Error('Expected invalid asset issuer to be rejected'); + } catch (error) { + expect(error).toBeInstanceOf(BatchPaymentError); + expect((error as Error).message).not.toContain(invalidAssetIssuer); + } }); }); }); diff --git a/backend/src/services/batch-payment.service.ts b/backend/src/services/batch-payment.service.ts index 7dd83d5..3e73e1a 100644 --- a/backend/src/services/batch-payment.service.ts +++ b/backend/src/services/batch-payment.service.ts @@ -11,17 +11,16 @@ import { Keypair, - Server, TransactionBuilder, Networks, Operation, Asset, - StrKey, Account, Horizon, } from '@stellar/stellar-sdk'; import { v4 as uuidv4 } from 'uuid'; import logger from '../utils/logger'; +import { isValidStellarPublicKey } from '../utils/stellar-address'; import { SequenceNumberManager } from './sequence-number.service'; import { getKeyManagementService } from '../lib/key-management.service'; import { KeyManagementError } from '../lib/key-management.types'; @@ -48,7 +47,7 @@ const DEFAULT_CONFIG: Partial = { export class BatchPaymentService { private config: BatchPaymentConfig; - private server: Server; + private server: Horizon.Server; private sequenceManager: SequenceNumberManager; constructor(config?: Partial) { @@ -57,7 +56,7 @@ export class BatchPaymentService { ...config, } as BatchPaymentConfig; - this.server = new Server(this.config.horizonUrl); + this.server = new Horizon.Server(this.config.horizonUrl); this.sequenceManager = new SequenceNumberManager( this.config.redisKeyPrefix, this.config.lockTimeoutSeconds @@ -200,7 +199,7 @@ export class BatchPaymentService { payments: PaymentOperation[], sourceSecretKey?: string, chunkSize: number = 100, - encryptedKey?: any, + encryptedKey?: BatchPaymentRequest['encryptedKey'], keyId?: string ): Promise { const results: BatchPaymentResult[] = []; @@ -231,7 +230,7 @@ export class BatchPaymentService { async handlePartialFailure( failedPayments: PaymentOperation[], sourceSecretKey?: string, - encryptedKey?: any, + encryptedKey?: BatchPaymentRequest['encryptedKey'], keyId?: string ): Promise { if (failedPayments.length === 0) { @@ -320,19 +319,20 @@ export class BatchPaymentService { try { const submitResponse = await this.server.submitTransaction(builtTransaction); + const feeCharged = this.getSubmittedTransactionFee(submitResponse); const result: BatchPaymentResult = { transactionHash: submitResponse.hash, successfulOps: payments.length, totalOps: payments.length, - feePaid: parseInt(submitResponse.feeCharged, 10), + feePaid: feeCharged, sequenceNumber: sequenceNumber, ledger: submitResponse.ledger, timestamp: new Date(), }; logger.info( - `[Batch ${batchId}] Transaction successful: hash=${submitResponse.hash}, fee=${submitResponse.feeCharged}, ledger=${submitResponse.ledger}` + `[Batch ${batchId}] Transaction successful: hash=${submitResponse.hash}, fee=${feeCharged}, ledger=${submitResponse.ledger}` ); return result; @@ -341,7 +341,13 @@ export class BatchPaymentService { // Check if it's a HorizonApi error with result codes if (error && typeof error === 'object' && 'response' in error) { - const horizonError = error as Horizon.HorizonApi.ErrorResponse; + const horizonError = error as { + extras?: { + result_codes?: { + operations?: string[]; + }; + }; + }; // Handle partial failure scenarios if (horizonError.extras?.result_codes) { @@ -380,10 +386,10 @@ export class BatchPaymentService { const payment = payments[i]; // Validate destination address - if (!StrKey.isValidEd25519PublicKey(payment.destination)) { + if (!isValidStellarPublicKey(payment.destination)) { throw new BatchPaymentError( BatchErrorType.INVALID_ADDRESS, - `Invalid destination address at index ${i}: ${payment.destination}` + `Invalid destination Stellar address at index ${i}` ); } @@ -397,7 +403,7 @@ export class BatchPaymentService { // Validate asset if specified if (payment.assetCode && payment.assetCode !== 'XLM') { - if (!payment.assetIssuer || !StrKey.isValidEd25519PublicKey(payment.assetIssuer)) { + if (!isValidStellarPublicKey(payment.assetIssuer)) { throw new BatchPaymentError( BatchErrorType.INVALID_ASSET, `Invalid asset issuer at index ${i} for asset ${payment.assetCode}` @@ -407,6 +413,21 @@ export class BatchPaymentService { } } + /** + * Extract submitted transaction fees across Stellar SDK response shapes. + */ + private getSubmittedTransactionFee( + submitResponse: Horizon.HorizonApi.SubmitTransactionResponse + ): number { + const responseWithFee = submitResponse as Horizon.HorizonApi.SubmitTransactionResponse & { + feeCharged?: number | string; + fee_charged?: number | string; + }; + const fee = responseWithFee.fee_charged ?? responseWithFee.feeCharged ?? 0; + const parsedFee = Number.parseInt(String(fee), 10); + return Number.isFinite(parsedFee) ? parsedFee : 0; + } + /** * Create Stellar Asset object */ @@ -447,6 +468,7 @@ export class BatchPaymentService { * Get batch status (for tracking purposes) */ async getBatchStatus(batchId: string): Promise { + void batchId; // This would typically query a database // For now, return null as we're not storing batch status return null; diff --git a/backend/src/services/recurring-payments.service.ts b/backend/src/services/recurring-payments.service.ts index 26bfd7c..45cc676 100644 --- a/backend/src/services/recurring-payments.service.ts +++ b/backend/src/services/recurring-payments.service.ts @@ -1,7 +1,7 @@ import cronParser from 'cron-parser'; -import { StrKey } from '@stellar/stellar-sdk'; import prisma from '../lib/prisma'; import logger from '../utils/logger'; +import { isValidStellarPublicKey } from '../utils/stellar-address'; import { BatchPaymentService } from './batch-payment.service'; import { config } from '../config/env'; @@ -33,7 +33,7 @@ export class RecurringPaymentsService { } validateScheduleInput(input: RecurringPaymentScheduleInput): void { - if (!StrKey.isValidEd25519PublicKey(input.destination)) { + if (!isValidStellarPublicKey(input.destination)) { throw new Error('Invalid destination Stellar address'); } diff --git a/backend/src/services/sequence-number.service.ts b/backend/src/services/sequence-number.service.ts index 2c59b22..4b23468 100644 --- a/backend/src/services/sequence-number.service.ts +++ b/backend/src/services/sequence-number.service.ts @@ -5,11 +5,18 @@ * to prevent conflicts across concurrent workers. */ -import { AccountResponse } from '@stellar/stellar-sdk'; import { redis } from '../lib/redis'; import logger from '../utils/logger'; import { BatchPaymentError, BatchErrorType } from './batch-payment.types'; +type SequenceAccount = { + sequenceNumber: () => string; +}; + +type HorizonSequenceServer = { + loadAccount: (accountPublicKey: string) => Promise; +}; + export class SequenceNumberManager { private redisPrefix: string; private lockTimeout: number; @@ -113,7 +120,9 @@ export class SequenceNumberManager { try { // Delete the counter - next request will start from 1 await redis.del(seqKey); - logger.debug(`Sequence counter reset for account: ${accountPublicKey}`); + logger.debug( + `Sequence counter reset for account: ${accountPublicKey} at base sequence ${newBaseSequence}` + ); } catch (error) { logger.error(`Error resetting sequence counter: ${error}`); } @@ -124,7 +133,7 @@ export class SequenceNumberManager { */ async fetchSequenceFromNetwork( accountPublicKey: string, - horizonServer: any + horizonServer: HorizonSequenceServer ): Promise { try { const account = await horizonServer.loadAccount(accountPublicKey); diff --git a/backend/src/utils/stellar-address.test.ts b/backend/src/utils/stellar-address.test.ts new file mode 100644 index 0000000..0e4bc82 --- /dev/null +++ b/backend/src/utils/stellar-address.test.ts @@ -0,0 +1,28 @@ +/// +import { isValidStellarPublicKey } from './stellar-address'; + +const VALID_PUBLIC_KEY = 'GCM5WPR4DDR24FSAX5LIEM4J7AI3KOWJYANSXEPKYXCSZOTAYXE75AFN'; + +describe('isValidStellarPublicKey', () => { + it('accepts a canonical Stellar Ed25519 public key', () => { + expect(isValidStellarPublicKey(VALID_PUBLIC_KEY)).toBe(true); + }); + + it.each([ + ['empty string', ''], + ['whitespace-only string', ' '], + ['leading whitespace', ` ${VALID_PUBLIC_KEY}`], + ['trailing whitespace', `${VALID_PUBLIC_KEY} `], + ['lowercase public key', VALID_PUBLIC_KEY.toLowerCase()], + ['checksum mismatch', `${VALID_PUBLIC_KEY.slice(0, -1)}A`], + ['secret seed-like value', `S${VALID_PUBLIC_KEY.slice(1)}`], + ['muxed account-like value', `M${VALID_PUBLIC_KEY.slice(1)}`], + ['contract address-like value', `C${VALID_PUBLIC_KEY.slice(1)}`], + ['null', null], + ['undefined', undefined], + ['number', 12345], + ['object', { publicKey: VALID_PUBLIC_KEY }], + ])('rejects %s', (_caseName, value) => { + expect(isValidStellarPublicKey(value)).toBe(false); + }); +}); diff --git a/backend/src/utils/stellar-address.ts b/backend/src/utils/stellar-address.ts new file mode 100644 index 0000000..c28a3fe --- /dev/null +++ b/backend/src/utils/stellar-address.ts @@ -0,0 +1,23 @@ +import { StrKey } from '@stellar/stellar-sdk'; + +const STELLAR_PUBLIC_KEY_LENGTH = 56; +const STELLAR_PUBLIC_KEY_PREFIX = 'G'; + +/** + * Validates canonical Stellar Ed25519 account public keys. + * + * The application accepts classic account IDs only (`G...`). Inputs such as + * secret seeds (`S...`), muxed accounts (`M...`), contract IDs (`C...`), empty + * strings, and padded values are intentionally rejected at request boundaries. + */ +export const isValidStellarPublicKey = (value: unknown): value is string => { + if ( + typeof value !== 'string' || + value.length !== STELLAR_PUBLIC_KEY_LENGTH || + value[0] !== STELLAR_PUBLIC_KEY_PREFIX + ) { + return false; + } + + return StrKey.isValidEd25519PublicKey(value); +}; diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 529dbd8..9abf4d9 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -9,6 +9,7 @@ "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, + "ignoreDeprecations": "6.0", "declaration": true, "sourceMap": true, "resolveJsonModule": true, @@ -16,6 +17,4 @@ }, "include": ["src/**/*"], "exclude": ["node_modules", "**/*.test.ts"] -} - "exclude": ["node_modules"] }