From dc12e875d374977fe7577b083f0af8e5fb2a49f2 Mon Sep 17 00:00:00 2001 From: afeez Date: Sat, 18 Jul 2026 12:12:35 +0100 Subject: [PATCH 1/2] feat: implement durable sponsored Stellar account funding --- .env.example | 8 +- .env.test | 3 + prisma/schema.prisma | 20 ++ src/config/stellar.ts | 9 + src/services/stellar-funding.service.ts | 236 +++++++++++++ tests/stellar-funding.service.test.ts | 449 ++++++++++++++++++++++++ 6 files changed, 724 insertions(+), 1 deletion(-) create mode 100644 src/services/stellar-funding.service.ts create mode 100644 tests/stellar-funding.service.test.ts diff --git a/.env.example b/.env.example index 1e91e787..2a31c10a 100644 --- a/.env.example +++ b/.env.example @@ -3,4 +3,10 @@ NODE_ENV=development STELLAR_NETWORK=testnet JWT_SECRET=your_jwt_secret # Database Configuration -DATABASE_URL="postgresql://username:password@localhost:5432/learnault_db?schema=public" \ No newline at end of file +DATABASE_URL="postgresql://username:password@localhost:5432/learnault_db?schema=public" + +# Stellar Funding Configuration +STELLAR_FUNDING_AMOUNT=10 +STELLAR_FUNDING_MIN_BALANCE=1 +STELLAR_FUNDING_MAX_RETRIES=5 +# STELLAR_FUNDING_SOURCE_SECRET=S... (funding source account secret — set via secure env, never committed) \ No newline at end of file diff --git a/.env.test b/.env.test index ab879449..d9594dce 100644 --- a/.env.test +++ b/.env.test @@ -1,5 +1,8 @@ PORT=5000 NODE_ENV=development STELLAR_NETWORK=testnet # testnet | mainnet (default: testnet) +STELLAR_FUNDING_AMOUNT=10 +STELLAR_FUNDING_MIN_BALANCE=1 +STELLAR_FUNDING_MAX_RETRIES=5 JWT_SECRET=your_jwt_secret DATABASE_URL=postgresql://user:password@localhost:5432/learnault \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c3dca009..f0946f0c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -241,3 +241,23 @@ model NotificationLog { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +model StellarFunding { + id String @id @default(uuid()) + publicKey String @unique + amount String + status String @default("pending") // pending, submitted, confirmed, dead-letter + transactionHash String? + ledger Int? + retryCount Int @default(0) + maxRetries Int @default(5) + nextAttemptAt DateTime? @default(now()) + lastAttemptAt DateTime? + error String? + confirmedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status, nextAttemptAt]) + @@map("stellar_fundings") +} diff --git a/src/config/stellar.ts b/src/config/stellar.ts index e69de29b..b86d8e30 100644 --- a/src/config/stellar.ts +++ b/src/config/stellar.ts @@ -0,0 +1,9 @@ +export const stellarConfig = { + network: (process.env.STELLAR_NETWORK as 'testnet' | 'mainnet') ?? 'testnet', + funding: { + amount: process.env.STELLAR_FUNDING_AMOUNT ?? '10', + minBalance: process.env.STELLAR_FUNDING_MIN_BALANCE ?? '1', + maxRetries: parseInt(process.env.STELLAR_FUNDING_MAX_RETRIES ?? '5', 10), + backoffBaseMinutes: 5, + }, +} diff --git a/src/services/stellar-funding.service.ts b/src/services/stellar-funding.service.ts new file mode 100644 index 00000000..a8e6b343 --- /dev/null +++ b/src/services/stellar-funding.service.ts @@ -0,0 +1,236 @@ +import prisma from '../config/database' +import { stellarConfig } from '../config/stellar' +import { StellarService, StellarServiceError } from './stellar.service' +import logger from '../utils/logger' + +interface StellarFundingRecord { + id: string + publicKey: string + amount: string + status: string + transactionHash: string | null + ledger: number | null + retryCount: number + maxRetries: number + nextAttemptAt: Date | null + lastAttemptAt: Date | null + error: string | null + confirmedAt: Date | null + createdAt: Date + updatedAt: Date +} + +export class StellarFundingService { + private stellarService: StellarService + + constructor(stellarService?: StellarService) { + this.stellarService = stellarService ?? new StellarService() + } + + async queueFunding(publicKey: string): Promise { + const existing = await prisma.stellarFunding.findUnique({ + where: { publicKey }, + }) + + if (existing) { + this.processQueue().catch((err) => + logger.error('[StellarFundingService] Queue processing error:', err) + ) + return existing as unknown as StellarFundingRecord + } + + const funding = await prisma.stellarFunding.create({ + data: { + publicKey, + amount: stellarConfig.funding.amount, + status: 'pending', + nextAttemptAt: new Date(), + }, + }) + + this.processQueue().catch((err) => + logger.error('[StellarFundingService] Queue processing error:', err) + ) + + return funding as unknown as StellarFundingRecord + } + + async processQueue(): Promise { + const pending = await prisma.stellarFunding.findMany({ + where: { + status: { in: ['pending', 'submitted'] }, + nextAttemptAt: { lte: new Date() }, + retryCount: { lt: stellarConfig.funding.maxRetries }, + }, + }) + + for (const record of pending) { + await this.processFunding(record as unknown as StellarFundingRecord) + } + } + + private async processFunding(record: StellarFundingRecord): Promise { + await prisma.stellarFunding.update({ + where: { id: record.id }, + data: { retryCount: { increment: 1 }, lastAttemptAt: new Date() }, + }) + + if (record.status === 'submitted') { + await this.reconcile(record) + return + } + + const alreadyFunded = await this.checkAlreadyFunded(record) + if (alreadyFunded) return + + const sourceSecret = process.env.STELLAR_FUNDING_SOURCE_SECRET + if (!sourceSecret) { + await this.handleFailure(record, 'Funding source secret not configured') + return + } + + try { + const result = await this.stellarService.sendPayment({ + sourceSecret, + destinationPublicKey: record.publicKey, + amount: record.amount, + memo: 'Account funding', + }) + + await this.markConfirmed( + record, + result.hash, + result.ledger + ) + } catch (err) { + if (err instanceof StellarServiceError) { + if (err.code === 'TRANSACTION_TIMEOUT') { + await prisma.stellarFunding.update({ + where: { id: record.id }, + data: { + status: 'submitted', + error: 'Transaction submitted, awaiting confirmation', + }, + }) + return + } + + if ( + err.code === 'PAYMENT_ERROR' && + this.isInsufficientFundsError(err) + ) { + await this.handleFailure(record, 'Insufficient funding source balance') + return + } + } + + const message = err instanceof Error ? err.message : 'Funding failed' + await this.handleFailure(record, message) + } + } + + private async reconcile(record: StellarFundingRecord): Promise { + const alreadyFunded = await this.checkAlreadyFunded(record) + if (alreadyFunded) return + + if (record.transactionHash) { + try { + const succeeded = await this.stellarService.verifyTransaction( + record.transactionHash + ) + if (succeeded) { + await this.markConfirmed( + record, + record.transactionHash, + record.ledger ?? undefined + ) + return + } + } catch { + // verification failed, fall through to retry + } + } + + await this.handleFailure( + record, + 'Reconciliation: funding not confirmed, retrying' + ) + } + + private async checkAlreadyFunded( + record: StellarFundingRecord + ): Promise { + try { + const balance = await this.stellarService.getNativeBalance( + record.publicKey + ) + if (parseFloat(balance) >= parseFloat(stellarConfig.funding.minBalance)) { + await this.markConfirmed(record) + return true + } + } catch { + // balance check failed, continue to submit + } + return false + } + + private async markConfirmed( + record: StellarFundingRecord, + transactionHash?: string, + ledger?: number + ): Promise { + const data: Record = { + status: 'confirmed', + confirmedAt: new Date(), + } + if (transactionHash) data.transactionHash = transactionHash + if (ledger !== undefined) data.ledger = ledger + + await prisma.stellarFunding.update({ + where: { id: record.id }, + data, + }) + } + + private async handleFailure( + record: StellarFundingRecord, + error: string + ): Promise { + const nextAttemptCount = record.retryCount + 1 + + if (nextAttemptCount >= record.maxRetries) { + await prisma.stellarFunding.update({ + where: { id: record.id }, + data: { status: 'dead-letter', error }, + }) + } else { + const backoffMinutes = Math.pow( + stellarConfig.funding.backoffBaseMinutes, + nextAttemptCount - 1 + ) + const nextAttemptAt = new Date( + Date.now() + backoffMinutes * 60_000 + ) + + await prisma.stellarFunding.update({ + where: { id: record.id }, + data: { + status: 'pending', + error, + nextAttemptAt, + }, + }) + } + } + + private isInsufficientFundsError(err: StellarServiceError): boolean { + const causeMessage = + err.cause instanceof Error ? err.cause.message : String(err.cause ?? '') + return ( + causeMessage.includes('op_underfunded') || + causeMessage.includes('insufficient') + ) + } +} + +export const stellarFundingService = new StellarFundingService() diff --git a/tests/stellar-funding.service.test.ts b/tests/stellar-funding.service.test.ts new file mode 100644 index 00000000..b0add9c8 --- /dev/null +++ b/tests/stellar-funding.service.test.ts @@ -0,0 +1,449 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { StellarFundingService } from '../src/services/stellar-funding.service' +import { StellarServiceError } from '../src/services/stellar.service' +import type { StellarService } from '../src/services/stellar.service' + +const { mockFindUnique, mockCreate, mockFindMany, mockUpdate } = + vi.hoisted(() => ({ + mockFindUnique: vi.fn(), + mockCreate: vi.fn(), + mockFindMany: vi.fn(), + mockUpdate: vi.fn(), + })) + +vi.mock('../src/config/database', () => ({ + default: { + stellarFunding: { + findUnique: mockFindUnique, + create: mockCreate, + findMany: mockFindMany, + update: mockUpdate, + }, + }, +})) + +const { mockConfigAmount, mockConfigMinBalance } = vi.hoisted(() => ({ + mockConfigAmount: vi.fn(() => '10'), + mockConfigMinBalance: vi.fn(() => '1'), +})) + +vi.mock('../src/config/stellar', () => ({ + stellarConfig: { + network: 'testnet', + funding: { + get amount() { return mockConfigAmount() }, + get minBalance() { return mockConfigMinBalance() }, + maxRetries: 5, + backoffBaseMinutes: 5, + }, + }, +})) + +const PUBLIC_KEY = 'GABCDEF12345678901234567890123456789012345678901234567890123' +const FUNDING_AMOUNT = '10' + +describe('StellarFundingService', () => { + let stellarMock: StellarService + let service: StellarFundingService + + beforeEach(() => { + vi.clearAllMocks() + mockFindMany.mockResolvedValue([]) + stellarMock = { + getNativeBalance: vi.fn(), + sendPayment: vi.fn(), + verifyTransaction: vi.fn(), + } as unknown as StellarService + service = new StellarFundingService(stellarMock) + + mockConfigAmount.mockReturnValue('10') + mockConfigMinBalance.mockReturnValue('1') + process.env.STELLAR_FUNDING_SOURCE_SECRET = 'SFAKE_SECRET_KEY' + }) + + describe('queueFunding', () => { + it('creates a new funding record when none exists', async () => { + mockFindUnique.mockResolvedValue(null) + mockCreate.mockResolvedValue({ + id: 'fund-1', + publicKey: PUBLIC_KEY, + amount: FUNDING_AMOUNT, + status: 'pending', + nextAttemptAt: new Date(), + }) + + const result = await service.queueFunding(PUBLIC_KEY) + + expect(mockCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + publicKey: PUBLIC_KEY, + amount: FUNDING_AMOUNT, + status: 'pending', + }), + }) + expect(result.publicKey).toBe(PUBLIC_KEY) + }) + + it('returns existing record on duplicate request', async () => { + const existing = { + id: 'fund-1', + publicKey: PUBLIC_KEY, + amount: FUNDING_AMOUNT, + status: 'pending', + } + mockFindUnique.mockResolvedValue(existing) + + const result = await service.queueFunding(PUBLIC_KEY) + + expect(mockCreate).not.toHaveBeenCalled() + expect(result.publicKey).toBe(PUBLIC_KEY) + }) + }) + + describe('processQueue - already funded', () => { + it('marks as confirmed when account already meets minimum balance', async () => { + const record = makeRecord({ status: 'pending' }) + mockFindMany.mockResolvedValue([record]) + vi.mocked(stellarMock.getNativeBalance).mockResolvedValue('5.0000000') + + await service.processQueue() + + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: record.id }, + data: expect.objectContaining({ status: 'confirmed' }), + }) + ) + expect(stellarMock.sendPayment).not.toHaveBeenCalled() + }) + }) + + describe('processQueue - successful funding', () => { + beforeEach(() => { + vi.mocked(stellarMock.getNativeBalance).mockResolvedValue('0') + vi.mocked(stellarMock.sendPayment).mockResolvedValue({ + hash: 'TXHASH123', + ledger: 42, + successful: true, + }) + }) + + it('submits payment and marks confirmed', async () => { + const record = makeRecord({ status: 'pending' }) + mockFindMany.mockResolvedValue([record]) + + await service.processQueue() + + expect(stellarMock.sendPayment).toHaveBeenCalledWith( + expect.objectContaining({ + sourceSecret: 'SFAKE_SECRET_KEY', + destinationPublicKey: record.publicKey, + amount: FUNDING_AMOUNT, + }) + ) + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: record.id }, + data: expect.objectContaining({ + status: 'confirmed', + transactionHash: 'TXHASH123', + ledger: 42, + }), + }) + ) + }) + }) + + describe('processQueue - timeout after submit', () => { + it('sets status to submitted when transaction times out', async () => { + const record = makeRecord({ status: 'pending' }) + mockFindMany.mockResolvedValue([record]) + vi.mocked(stellarMock.getNativeBalance).mockResolvedValue('0') + vi.mocked(stellarMock.sendPayment).mockRejectedValue( + new StellarServiceError( + 'Transaction TXHASH123 not confirmed after 20 attempts', + 'TRANSACTION_TIMEOUT' + ) + ) + + await service.processQueue() + + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: record.id }, + data: expect.objectContaining({ + status: 'submitted', + error: 'Transaction submitted, awaiting confirmation', + }), + }) + ) + }) + }) + + describe('processQueue - reconciliation', () => { + it('marks confirmed when account is funded during reconciliation', async () => { + const record = makeRecord({ status: 'submitted' }) + mockFindMany.mockResolvedValue([record]) + vi.mocked(stellarMock.getNativeBalance).mockResolvedValue('5.0000000') + + await service.processQueue() + + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: record.id }, + data: expect.objectContaining({ status: 'confirmed' }), + }) + ) + }) + + it('marks confirmed when persisted transaction hash is valid', async () => { + const record = makeRecord({ + status: 'submitted', + transactionHash: 'TXHASH123', + ledger: 42, + }) + mockFindMany.mockResolvedValue([record]) + vi.mocked(stellarMock.getNativeBalance).mockResolvedValue('0') + vi.mocked(stellarMock.verifyTransaction).mockResolvedValue(true) + + await service.processQueue() + + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: record.id }, + data: expect.objectContaining({ + status: 'confirmed', + transactionHash: 'TXHASH123', + ledger: 42, + }), + }) + ) + }) + + it('schedules retry when reconciliation cannot confirm', async () => { + const record = makeRecord({ + status: 'submitted', + transactionHash: 'TXHASH123', + retryCount: 1, + }) + mockFindMany.mockResolvedValue([record]) + vi.mocked(stellarMock.getNativeBalance).mockResolvedValue('0') + vi.mocked(stellarMock.verifyTransaction).mockResolvedValue(false) + + await service.processQueue() + + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: record.id }, + data: expect.objectContaining({ + status: 'pending', + error: 'Reconciliation: funding not confirmed, retrying', + }), + }) + ) + }) + }) + + describe('processQueue - idempotent execution', () => { + it('does not submit a new transaction when record is already confirmed', async () => { + mockFindMany.mockResolvedValue([]) + + await service.processQueue() + + expect(stellarMock.sendPayment).not.toHaveBeenCalled() + }) + + it('skips funding when already funded on retry', async () => { + const record = makeRecord({ + status: 'pending', + retryCount: 1, + }) + mockFindMany.mockResolvedValue([record]) + vi.mocked(stellarMock.getNativeBalance).mockResolvedValue('5.0000000') + + await service.processQueue() + + expect(stellarMock.sendPayment).not.toHaveBeenCalled() + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'confirmed' }), + }) + ) + }) + }) + + describe('processQueue - retry behavior', () => { + it('increments retryCount on each attempt', async () => { + const record = makeRecord({ status: 'pending', retryCount: 0 }) + mockFindMany.mockResolvedValue([record]) + vi.mocked(stellarMock.getNativeBalance).mockResolvedValue('0') + vi.mocked(stellarMock.sendPayment).mockRejectedValue(new Error('Network error')) + + await service.processQueue() + + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: record.id }, + data: expect.objectContaining({ + retryCount: { increment: 1 }, + lastAttemptAt: expect.any(Date), + }), + }) + ) + }) + + it('dead-letters after max retries', async () => { + const record = makeRecord({ + status: 'pending', + retryCount: 4, + maxRetries: 5, + }) + mockFindMany.mockResolvedValue([record]) + vi.mocked(stellarMock.getNativeBalance).mockResolvedValue('0') + vi.mocked(stellarMock.sendPayment).mockRejectedValue(new Error('Final failure')) + + await service.processQueue() + + const calls = mockUpdate.mock.calls + const deadLetterCall = calls.find( + (c: any[]) => c[0]?.data?.status === 'dead-letter' + ) + expect(deadLetterCall).toBeDefined() + }) + + it('does not fetch records that exceeded maxRetries', async () => { + mockFindMany.mockResolvedValue([]) + + await service.processQueue() + + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + retryCount: { lt: 5 }, + }), + }) + ) + }) + }) + + describe('processQueue - insufficient funds', () => { + it('handles op_underfunded error gracefully', async () => { + const record = makeRecord({ status: 'pending' }) + mockFindMany.mockResolvedValue([record]) + vi.mocked(stellarMock.getNativeBalance).mockResolvedValue('0') + + const cause = new Error('op_underfunded') + const paymentError = new StellarServiceError( + 'Payment transaction failed', + 'PAYMENT_ERROR', + cause + ) + vi.mocked(stellarMock.sendPayment).mockRejectedValue(paymentError) + + await service.processQueue() + + const errorUpdate = mockUpdate.mock.calls.find( + (c: any[]) => + c[0]?.data?.error?.includes('Insufficient funding source balance') + ) + expect(errorUpdate).toBeDefined() + }) + }) + + describe('processQueue - provider outage', () => { + it('handles network errors during balance check gracefully', async () => { + const record = makeRecord({ status: 'pending' }) + mockFindMany.mockResolvedValue([record]) + vi.mocked(stellarMock.getNativeBalance).mockRejectedValue( + new Error('Network timeout') + ) + vi.mocked(stellarMock.sendPayment).mockRejectedValue( + new Error('Also down') + ) + + await service.processQueue() + + expect(stellarMock.sendPayment).toHaveBeenCalled() + }) + + it('handles network errors during payment submission', async () => { + const record = makeRecord({ status: 'pending' }) + mockFindMany.mockResolvedValue([record]) + vi.mocked(stellarMock.getNativeBalance).mockResolvedValue('0') + vi.mocked(stellarMock.sendPayment).mockRejectedValue( + new Error('Horizon unreachable') + ) + + await service.processQueue() + + const errorUpdate = mockUpdate.mock.calls.find( + (c: any[]) => c[0]?.data?.error === 'Horizon unreachable' + ) + expect(errorUpdate).toBeDefined() + }) + }) + + describe('funding policy enforcement', () => { + it('uses configured funding amount from config', async () => { + mockConfigAmount.mockReturnValue('25') + service = new StellarFundingService(stellarMock) + + mockFindUnique.mockResolvedValue(null) + mockCreate.mockResolvedValue({ + id: 'fund-1', + publicKey: PUBLIC_KEY, + amount: '25', + status: 'pending', + }) + + await service.queueFunding(PUBLIC_KEY) + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ amount: '25' }), + }) + ) + }) + + it('uses configured minimum balance to determine already-funded', async () => { + mockConfigMinBalance.mockReturnValue('10') + service = new StellarFundingService(stellarMock) + + const record = makeRecord({ status: 'pending' }) + mockFindMany.mockResolvedValue([record]) + vi.mocked(stellarMock.getNativeBalance).mockResolvedValue('9.0000000') + + await service.processQueue() + + expect(stellarMock.sendPayment).toHaveBeenCalled() + }) + }) + + describe('security', () => { + it('funding secret is never persisted in the database record', () => { + const record = makeRecord({ status: 'pending' }) + expect(Object.keys(record)).not.toContain('secretKey') + expect(Object.keys(record)).not.toContain('secret') + }) + }) +}) + +function makeRecord(overrides: Record = {}) { + return { + id: 'fund-1', + publicKey: PUBLIC_KEY, + amount: FUNDING_AMOUNT, + status: 'pending', + transactionHash: null, + ledger: null, + retryCount: 0, + maxRetries: 5, + nextAttemptAt: new Date(), + lastAttemptAt: null, + error: null, + confirmedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } +} From 2c0921959b9cdc2719d7dbff2989f9371a5714e5 Mon Sep 17 00:00:00 2001 From: afeez Date: Sat, 18 Jul 2026 12:16:59 +0100 Subject: [PATCH 2/2] fix: resolve ESLint newline-before-return and brace-style violations --- src/services/stellar-funding.service.ts | 27 ++++++++++++++++--------- tests/stellar-funding.service.test.ts | 8 ++++++-- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/services/stellar-funding.service.ts b/src/services/stellar-funding.service.ts index a8e6b343..78e166fd 100644 --- a/src/services/stellar-funding.service.ts +++ b/src/services/stellar-funding.service.ts @@ -36,7 +36,8 @@ export class StellarFundingService { this.processQueue().catch((err) => logger.error('[StellarFundingService] Queue processing error:', err) ) - return existing as unknown as StellarFundingRecord + +return existing as unknown as StellarFundingRecord } const funding = await prisma.stellarFunding.create({ @@ -77,7 +78,8 @@ export class StellarFundingService { if (record.status === 'submitted') { await this.reconcile(record) - return + +return } const alreadyFunded = await this.checkAlreadyFunded(record) @@ -86,7 +88,8 @@ export class StellarFundingService { const sourceSecret = process.env.STELLAR_FUNDING_SOURCE_SECRET if (!sourceSecret) { await this.handleFailure(record, 'Funding source secret not configured') - return + +return } try { @@ -112,7 +115,8 @@ export class StellarFundingService { error: 'Transaction submitted, awaiting confirmation', }, }) - return + +return } if ( @@ -120,7 +124,8 @@ export class StellarFundingService { this.isInsufficientFundsError(err) ) { await this.handleFailure(record, 'Insufficient funding source balance') - return + +return } } @@ -144,7 +149,8 @@ export class StellarFundingService { record.transactionHash, record.ledger ?? undefined ) - return + +return } } catch { // verification failed, fall through to retry @@ -166,12 +172,14 @@ export class StellarFundingService { ) if (parseFloat(balance) >= parseFloat(stellarConfig.funding.minBalance)) { await this.markConfirmed(record) - return true + +return true } } catch { // balance check failed, continue to submit } - return false + +return false } private async markConfirmed( @@ -226,7 +234,8 @@ export class StellarFundingService { private isInsufficientFundsError(err: StellarServiceError): boolean { const causeMessage = err.cause instanceof Error ? err.cause.message : String(err.cause ?? '') - return ( + +return ( causeMessage.includes('op_underfunded') || causeMessage.includes('insufficient') ) diff --git a/tests/stellar-funding.service.test.ts b/tests/stellar-funding.service.test.ts index b0add9c8..8fa15732 100644 --- a/tests/stellar-funding.service.test.ts +++ b/tests/stellar-funding.service.test.ts @@ -31,8 +31,12 @@ vi.mock('../src/config/stellar', () => ({ stellarConfig: { network: 'testnet', funding: { - get amount() { return mockConfigAmount() }, - get minBalance() { return mockConfigMinBalance() }, + get amount() { + return mockConfigAmount() +}, + get minBalance() { + return mockConfigMinBalance() +}, maxRetries: 5, backoffBaseMinutes: 5, },