diff --git a/backend/src/modules/blockchain/blockchain.module.ts b/backend/src/modules/blockchain/blockchain.module.ts index 3da6f061c..db9b78c91 100644 --- a/backend/src/modules/blockchain/blockchain.module.ts +++ b/backend/src/modules/blockchain/blockchain.module.ts @@ -17,6 +17,7 @@ import { User } from '../user/entities/user.entity'; import { UserSubscription } from '../savings/entities/user-subscription.entity'; import { SavingsProduct } from '../savings/entities/savings-product.entity'; import { DepositHandler } from './event-handlers/deposit.handler'; +import { WithdrawHandler } from './event-handlers/withdraw.handler'; import { YieldHandler } from './event-handlers/yield.handler'; import { IndexerService } from './indexer.service'; @@ -47,6 +48,7 @@ import { IndexerService } from './indexer.service'; StellarEventListenerService, IndexerService, DepositHandler, + WithdrawHandler, YieldHandler, ], exports: [ @@ -56,6 +58,7 @@ import { IndexerService } from './indexer.service'; StellarEventListenerService, IndexerService, DepositHandler, + WithdrawHandler, YieldHandler, ], }) diff --git a/backend/src/modules/blockchain/event-handlers/withdraw.handler.spec.ts b/backend/src/modules/blockchain/event-handlers/withdraw.handler.spec.ts new file mode 100644 index 000000000..12954dadd --- /dev/null +++ b/backend/src/modules/blockchain/event-handlers/withdraw.handler.spec.ts @@ -0,0 +1,189 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DataSource } from 'typeorm'; +import { xdr, nativeToScVal } from '@stellar/stellar-sdk'; +import { createHash } from 'crypto'; +import { WithdrawHandler } from './withdraw.handler'; +import { + UserSubscription, + SubscriptionStatus, +} from '../../savings/entities/user-subscription.entity'; +import { User } from '../../user/entities/user.entity'; +import { + LedgerTransaction, + LedgerTransactionType, + LedgerTransactionStatus, +} from '../entities/transaction.entity'; + +describe('WithdrawHandler', () => { + let handler: WithdrawHandler; + let dataSource: any; + let entityManager: any; + + const WITHDRAW_HASH = createHash('sha256').update('Withdraw').digest('hex'); + + const userRepo = { + findOne: jest.fn(), + }; + const txRepo = { + findOne: jest.fn(), + save: jest.fn(), + create: jest.fn().mockImplementation((v) => v), + }; + const subRepo = { + findOne: jest.fn(), + save: jest.fn(), + create: jest.fn().mockImplementation((v) => v), + }; + + beforeEach(async () => { + entityManager = { + getRepository: jest.fn().mockImplementation((entity) => { + if (entity === User) return userRepo; + if (entity === LedgerTransaction) return txRepo; + if (entity === UserSubscription) return subRepo; + return null; + }), + decrement: jest.fn().mockResolvedValue({}), + }; + + dataSource = { + transaction: jest.fn().mockImplementation((cb) => cb(entityManager)), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WithdrawHandler, + { provide: DataSource, useValue: dataSource }, + ], + }).compile(); + + handler = module.get(WithdrawHandler); + }); + + it('should be defined', () => { + expect(handler).toBeDefined(); + }); + + describe('handle', () => { + const mockUser = { id: 'user-id', publicKey: 'G...' }; + const mockEvent = { + id: 'event-withdraw-1', + topic: [Buffer.from(WITHDRAW_HASH, 'hex').toString('base64')], + value: nativeToScVal({ publicKey: 'G...', amount: BigInt(200) }).toXDR( + 'base64', + ), + ledger: 200, + txHash: 'withdraw-tx-hash', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return false if topic does not match', async () => { + const wrongEvent = { ...mockEvent, topic: ['AAAA'] }; + const result = await handler.handle(wrongEvent); + expect(result).toBe(false); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it('should process withdraw successfully and decrement subscription natively', async () => { + userRepo.findOne.mockResolvedValue(mockUser); + txRepo.findOne.mockResolvedValue(null); + subRepo.findOne.mockResolvedValue({ + id: 'sub-id', + userId: 'user-id', + amount: 1000, + status: SubscriptionStatus.ACTIVE, + }); + + const result = await handler.handle(mockEvent); + + expect(result).toBe(true); + expect(txRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + type: LedgerTransactionType.WITHDRAW, + amount: '200', + status: LedgerTransactionStatus.COMPLETED, + }), + ); + expect(entityManager.decrement).toHaveBeenCalledWith( + UserSubscription, + { id: 'sub-id' }, + 'amount', + 200, + ); + }); + + it('should match topic by symbol', async () => { + const symbolEvent = { + ...mockEvent, + topic: [nativeToScVal('Withdraw', { type: 'symbol' }).toXDR('base64')], + }; + userRepo.findOne.mockResolvedValue(mockUser); + subRepo.findOne.mockResolvedValue({ + id: 'sub-id', + userId: 'user-id', + amount: 500, + status: SubscriptionStatus.ACTIVE, + }); + + const result = await handler.handle(symbolEvent); + expect(result).toBe(true); + expect(txRepo.save).toHaveBeenCalled(); + expect(entityManager.decrement).toHaveBeenCalled(); + }); + + it('should handle payload with "to" key and "value" amount', async () => { + const alternativeEvent = { + ...mockEvent, + value: nativeToScVal({ to: 'G...', value: BigInt(150) }).toXDR( + 'base64', + ), + }; + userRepo.findOne.mockResolvedValue(mockUser); + subRepo.findOne.mockResolvedValue({ + id: 'sub-id', + userId: 'user-id', + amount: 500, + status: SubscriptionStatus.ACTIVE, + }); + + const result = await handler.handle(alternativeEvent); + expect(result).toBe(true); + expect(txRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + amount: '150', + publicKey: 'G...', + }), + ); + }); + + it('should throw error if user not found', async () => { + userRepo.findOne.mockResolvedValue(null); + await expect(handler.handle(mockEvent)).rejects.toThrow( + 'Cannot map withdraw payload publicKey to user', + ); + }); + + it('should throw error if no active subscription found', async () => { + userRepo.findOne.mockResolvedValue(mockUser); + txRepo.findOne.mockResolvedValue(null); + subRepo.findOne.mockResolvedValue(null); + + await expect(handler.handle(mockEvent)).rejects.toThrow( + 'No active subscription found for user', + ); + }); + + it('should skip if event already persisted', async () => { + userRepo.findOne.mockResolvedValue(mockUser); + txRepo.findOne.mockResolvedValue({ id: 'existing-tx' }); + + const result = await handler.handle(mockEvent); + expect(result).toBe(true); // Handler returns true even if skipping to indicate event was "handled" (consumed) + expect(txRepo.save).not.toHaveBeenCalled(); + expect(entityManager.decrement).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/src/modules/blockchain/event-handlers/withdraw.handler.ts b/backend/src/modules/blockchain/event-handlers/withdraw.handler.ts index 013fbbec2..ac7bab101 100644 --- a/backend/src/modules/blockchain/event-handlers/withdraw.handler.ts +++ b/backend/src/modules/blockchain/event-handlers/withdraw.handler.ts @@ -5,6 +5,7 @@ import { DataSource } from 'typeorm'; import { scValToNative, xdr } from '@stellar/stellar-sdk'; import { LedgerTransaction, + LedgerTransactionStatus, LedgerTransactionType, } from '../entities/transaction.entity'; import { @@ -77,6 +78,7 @@ export class WithdrawHandler { amount: payload.amount, publicKey: payload.publicKey, eventId, + status: LedgerTransactionStatus.COMPLETED, transactionHash: typeof event.txHash === 'string' ? event.txHash : null, ledgerSequence: @@ -104,9 +106,13 @@ export class WithdrawHandler { ); } - subscription.amount = Number(subscription.amount) - amountAsNumber; - - await subRepo.save(subscription); + // Decrement the amount natively in the database to ensure atomicity and precision + await manager.decrement( + UserSubscription, + { id: subscription.id }, + 'amount', + amountAsNumber, + ); }); return true; @@ -119,7 +125,25 @@ export class WithdrawHandler { const first = topic[0]; const normalized = this.toHex(first); - return normalized === WithdrawHandler.WITHDRAW_HASH_HEX; + + // Some contracts emit the symbol 'Withdraw' directly, others emit its SHA256 hash + if (normalized === WithdrawHandler.WITHDRAW_HASH_HEX) { + return true; + } + + // Check if it's the symbol 'Withdraw' (XDR encoded) + if (typeof first === 'string') { + try { + const scVal = xdr.ScVal.fromXDR(first, 'base64'); + if (scValToNative(scVal) === 'Withdraw') { + return true; + } + } catch { + // Not XDR, ignore + } + } + + return false; } private extractPayload(value: unknown): WithdrawPayload { @@ -132,8 +156,11 @@ export class WithdrawHandler { 'userPublicKey', 'user', 'address', + 'to', + 'from', ]) ?? ''; - const amountRaw = asRecord['amount']; + const amountRaw = + asRecord['amount'] ?? asRecord['value'] ?? asRecord['amt']; const amount = typeof amountRaw === 'bigint' diff --git a/backend/src/modules/blockchain/event-handlers/yield.handler.spec.ts b/backend/src/modules/blockchain/event-handlers/yield.handler.spec.ts new file mode 100644 index 000000000..88dd0ca1b --- /dev/null +++ b/backend/src/modules/blockchain/event-handlers/yield.handler.spec.ts @@ -0,0 +1,200 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DataSource } from 'typeorm'; +import { xdr, nativeToScVal } from '@stellar/stellar-sdk'; +import { createHash } from 'crypto'; +import { YieldHandler } from './yield.handler'; +import { + UserSubscription, + SubscriptionStatus, +} from '../../savings/entities/user-subscription.entity'; +import { User } from '../../user/entities/user.entity'; +import { + LedgerTransaction, + LedgerTransactionType, + LedgerTransactionStatus, +} from '../entities/transaction.entity'; + +describe('YieldHandler', () => { + let handler: YieldHandler; + let dataSource: any; + let entityManager: any; + + const YIELD_HASH = createHash('sha256').update('Yield').digest('hex'); + const YLD_DIST_HASH = createHash('sha256').update('yld_dist').digest('hex'); + + const userRepo = { + findOne: jest.fn(), + }; + const txRepo = { + findOne: jest.fn(), + save: jest.fn(), + create: jest.fn().mockImplementation((v) => v), + }; + const subRepo = { + findOne: jest.fn(), + save: jest.fn(), + create: jest.fn().mockImplementation((v) => v), + }; + + beforeEach(async () => { + entityManager = { + getRepository: jest.fn().mockImplementation((entity) => { + if (entity === User) return userRepo; + if (entity === LedgerTransaction) return txRepo; + if (entity === UserSubscription) return subRepo; + return null; + }), + increment: jest.fn().mockResolvedValue({}), + }; + + dataSource = { + transaction: jest.fn().mockImplementation((cb) => cb(entityManager)), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [YieldHandler, { provide: DataSource, useValue: dataSource }], + }).compile(); + + handler = module.get(YieldHandler); + }); + + it('should be defined', () => { + expect(handler).toBeDefined(); + }); + + describe('handle', () => { + const mockUser = { id: 'user-id', publicKey: 'G...' }; + const mockEvent = { + id: 'event-yield-1', + topic: [Buffer.from(YIELD_HASH, 'hex').toString('base64')], + value: nativeToScVal({ publicKey: 'G...', yield: BigInt(50) }).toXDR( + 'base64', + ), + ledger: 300, + txHash: 'yield-tx-hash', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return false if topic does not match', async () => { + const wrongEvent = { ...mockEvent, topic: ['AAAA'] }; + const result = await handler.handle(wrongEvent); + expect(result).toBe(false); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it('should process yield successfully and increment interest natively', async () => { + userRepo.findOne.mockResolvedValue(mockUser); + txRepo.findOne.mockResolvedValue(null); + subRepo.findOne.mockResolvedValue({ + id: 'sub-id', + userId: 'user-id', + status: SubscriptionStatus.ACTIVE, + }); + + const result = await handler.handle(mockEvent); + + expect(result).toBe(true); + expect(txRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + type: LedgerTransactionType.YIELD, + amount: '50', + status: LedgerTransactionStatus.COMPLETED, + }), + ); + expect(entityManager.increment).toHaveBeenCalledWith( + UserSubscription, + { id: 'sub-id' }, + 'totalInterestEarned', + 50, + ); + }); + + it('should match topic by symbol "YieldPayout"', async () => { + const symbolEvent = { + ...mockEvent, + topic: [ + nativeToScVal('YieldPayout', { type: 'symbol' }).toXDR('base64'), + ], + }; + userRepo.findOne.mockResolvedValue(mockUser); + subRepo.findOne.mockResolvedValue({ + id: 'sub-id', + userId: 'user-id', + status: SubscriptionStatus.ACTIVE, + }); + + const result = await handler.handle(symbolEvent); + expect(result).toBe(true); + expect(txRepo.save).toHaveBeenCalled(); + expect(entityManager.increment).toHaveBeenCalled(); + }); + + it('should handle array-based payload for "yld_dist"', async () => { + const arrayEvent = { + id: 'event-yld-dist-1', + topic: [Buffer.from(YLD_DIST_HASH, 'hex').toString('base64')], + // [publicKey, total_yield, fee, net_yield] + value: nativeToScVal([ + 'G...', + BigInt(100), + BigInt(10), + BigInt(90), + ]).toXDR('base64'), + ledger: 301, + txHash: 'yld-dist-tx-hash', + }; + userRepo.findOne.mockResolvedValue(mockUser); + subRepo.findOne.mockResolvedValue({ + id: 'sub-id', + userId: 'user-id', + status: SubscriptionStatus.ACTIVE, + }); + + const result = await handler.handle(arrayEvent); + expect(result).toBe(true); + expect(txRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + amount: '90', + publicKey: 'G...', + }), + ); + expect(entityManager.increment).toHaveBeenCalledWith( + UserSubscription, + { id: 'sub-id' }, + 'totalInterestEarned', + 90, + ); + }); + + it('should throw error if user not found', async () => { + userRepo.findOne.mockResolvedValue(null); + await expect(handler.handle(mockEvent)).rejects.toThrow( + 'Cannot map yield payload publicKey to user', + ); + }); + + it('should skip updating interest if no active subscription found (but still record tx)', async () => { + userRepo.findOne.mockResolvedValue(mockUser); + txRepo.findOne.mockResolvedValue(null); + subRepo.findOne.mockResolvedValue(null); + + const result = await handler.handle(mockEvent); + expect(result).toBe(true); + expect(txRepo.save).toHaveBeenCalled(); + expect(entityManager.increment).not.toHaveBeenCalled(); + }); + + it('should skip if event already persisted', async () => { + userRepo.findOne.mockResolvedValue(mockUser); + txRepo.findOne.mockResolvedValue({ id: 'existing-tx' }); + + const result = await handler.handle(mockEvent); + expect(result).toBe(true); + expect(txRepo.save).not.toHaveBeenCalled(); + expect(entityManager.increment).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/src/modules/blockchain/event-handlers/yield.handler.ts b/backend/src/modules/blockchain/event-handlers/yield.handler.ts index 256f18fe4..82f3584dd 100644 --- a/backend/src/modules/blockchain/event-handlers/yield.handler.ts +++ b/backend/src/modules/blockchain/event-handlers/yield.handler.ts @@ -5,6 +5,7 @@ import { DataSource } from 'typeorm'; import { scValToNative, xdr } from '@stellar/stellar-sdk'; import { LedgerTransaction, + LedgerTransactionStatus, LedgerTransactionType, } from '../entities/transaction.entity'; import { @@ -78,6 +79,7 @@ export class YieldHandler { amount: payload.amount, publicKey: payload.publicKey, eventId, + status: LedgerTransactionStatus.COMPLETED, transactionHash: typeof event.txHash === 'string' ? event.txHash : null, ledgerSequence: @@ -100,10 +102,13 @@ export class YieldHandler { }); if (subscription) { - subscription.totalInterestEarned = String( - Number(subscription.totalInterestEarned || '0') + amountAsNumber, + // Increment the totalInterestEarned natively in the database to ensure absolute precision + await manager.increment( + UserSubscription, + { id: subscription.id }, + 'totalInterestEarned', + amountAsNumber, ); - await subRepo.save(subscription); } else { this.logger.warn( `No active subscription found for user ${user.id} to apply yield to.`, @@ -122,15 +127,39 @@ export class YieldHandler { const first = topic[0]; const normalized = this.toHex(first); - // Also check for 'yld_dist' which is emitted by the contract strategy + // Common topic hashes for yield events const YLD_DIST_HASH_HEX = createHash('sha256') .update('yld_dist') .digest('hex'); - return ( + const YIELD_PAYOUT_HASH_HEX = createHash('sha256') + .update('YieldPayout') // Some contracts use YieldPayout explicitly + .digest('hex'); + + if ( normalized === YieldHandler.YIELD_HASH_HEX || - normalized === YLD_DIST_HASH_HEX - ); + normalized === YLD_DIST_HASH_HEX || + normalized === YIELD_PAYOUT_HASH_HEX + ) { + return true; + } + + // Check if it's a Symbol XDR (Yield, YieldPayout, or yld_dist) + if (typeof first === 'string') { + try { + const scVal = xdr.ScVal.fromXDR(first, 'base64'); + const symbol = scValToNative(scVal); + return ( + symbol === 'Yield' || + symbol === 'YieldPayout' || + symbol === 'yld_dist' + ); + } catch { + // Not XDR, ignore + } + } + + return false; } private extractPayload(value: unknown): YieldPayload { @@ -145,10 +174,12 @@ export class YieldHandler { 'address', ]) ?? ''; const amountRaw = - asRecord['amount'] || - asRecord['yield'] || - asRecord['user_yield'] || - asRecord['actual_yield']; + asRecord['amount'] ?? + asRecord['yield'] ?? + asRecord['interest'] ?? + asRecord['user_yield'] ?? + asRecord['actual_yield'] ?? + asRecord['payout']; const amount = typeof amountRaw === 'bigint' @@ -211,11 +242,12 @@ export class YieldHandler { // Handle the case where value is an array (like in yld_dist event containing [strategy, actual_yield, treasury_fee, user_yield]) if (Array.isArray(value)) { - // If it's an array without keys, assume the first element is address, inner yield is index 3 - if (value.length >= 4) { + // If it's an array without keys, we need to map it carefully. + // yld_dist typically: [publicKey, total_yield, fee, net_yield] + if (value.length >= 2) { return { - address: value[0], - user_yield: value[3], + publicKey: value[0], + amount: value[3] ?? value[1], // Try the net_yield (index 3) first, else total (index 1) }; } } diff --git a/backend/src/modules/blockchain/indexer.service.dlq.spec.ts b/backend/src/modules/blockchain/indexer.service.dlq.spec.ts new file mode 100644 index 000000000..a9739bc99 --- /dev/null +++ b/backend/src/modules/blockchain/indexer.service.dlq.spec.ts @@ -0,0 +1,137 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ConfigService } from '@nestjs/config'; +import { Logger } from '@nestjs/common'; +import { IndexerService } from './indexer.service'; +import { IndexerState } from './entities/indexer-state.entity'; +import { DeadLetterEvent } from './entities/dead-letter-event.entity'; +import { SavingsProduct } from '../savings/entities/savings-product.entity'; +import { StellarService } from './stellar.service'; +import { DepositHandler } from './event-handlers/deposit.handler'; +import { WithdrawHandler } from './event-handlers/withdraw.handler'; +import { YieldHandler } from './event-handlers/yield.handler'; + +describe('IndexerService (DLQ Integration)', () => { + let service: IndexerService; + let deadLetterRepo: any; + let depositHandler: any; + let withdrawHandler: any; + let yieldHandler: any; + let stellarService: any; + + beforeEach(async () => { + deadLetterRepo = { + save: jest.fn().mockImplementation((val) => Promise.resolve(val)), + create: jest.fn().mockImplementation((val) => val), + }; + + const indexerStateRepo = { + findOne: jest.fn().mockResolvedValue({ + lastProcessedLedger: 100, + totalEventsProcessed: 0, + totalEventsFailed: 0, + }), + save: jest.fn(), + create: jest.fn(), + }; + + const savingsProductRepo = { + find: jest.fn().mockResolvedValue([{ contractId: 'C1', isActive: true }]), + }; + + stellarService = { + getRpcServer: jest.fn(), + getEvents: jest.fn(), + }; + + depositHandler = { handle: jest.fn() }; + withdrawHandler = { handle: jest.fn() }; + yieldHandler = { handle: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + IndexerService, + { provide: ConfigService, useValue: { get: jest.fn() } }, + { provide: StellarService, useValue: stellarService }, + { + provide: getRepositoryToken(IndexerState), + useValue: indexerStateRepo, + }, + { + provide: getRepositoryToken(DeadLetterEvent), + useValue: deadLetterRepo, + }, + { + provide: getRepositoryToken(SavingsProduct), + useValue: savingsProductRepo, + }, + { provide: DepositHandler, useValue: depositHandler }, + { provide: WithdrawHandler, useValue: withdrawHandler }, + { provide: YieldHandler, useValue: yieldHandler }, + ], + }).compile(); + + service = module.get(IndexerService); + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => null); + }); + + it('should capture a failing event and save it to the DLQ', async () => { + // Arrange + const mockEvent = { + id: 'event-fail-1', + ledger: 105, + topic: ['Withdraw'], + value: 'corrupted-data', + txHash: 'tx-fail-hash', + }; + + // Simulate handlers - deposit and withdraw skip (return false), yield throws + depositHandler.handle.mockResolvedValue(false); + withdrawHandler.handle.mockResolvedValue(false); + yieldHandler.handle.mockRejectedValue( + new Error('Decoding error at ledger 105'), + ); + + // Act + await service.onModuleInit(); + const result = await (service as any).processEvent(mockEvent); + + // Assert + expect(result).toBe(false); + expect(deadLetterRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + ledgerSequence: 105, + rawEvent: JSON.stringify(mockEvent), + errorMessage: 'Decoding error at ledger 105', + }), + ); + expect(Logger.prototype.error).toHaveBeenCalledWith( + expect.stringContaining('FAILURE at Ledger 105'), + ); + }); + + it('should continue processing the cycle even if one event fails', async () => { + // Arrange + const mockEvents = [ + { id: '1', ledger: 101, topic: ['T1'], value: 'V1' }, + { id: '2', ledger: 102, topic: ['T2'], value: 'V2' }, + { id: '3', ledger: 103, topic: ['T3'], value: 'V3' }, + ]; + stellarService.getEvents.mockResolvedValue(mockEvents); + + // Event 2 will fail + depositHandler.handle.mockImplementation(async (e) => { + if (e.id === '2') throw new Error('Crash'); + return true; + }); + + // Act + await service.onModuleInit(); + await service.runIndexerCycle(); + + // Assert + expect(deadLetterRepo.save).toHaveBeenCalledTimes(1); + expect(service.getIndexerState()?.totalEventsProcessed).toBe(2); + expect(service.getIndexerState()?.totalEventsFailed).toBe(1); + }); +}); diff --git a/backend/src/modules/blockchain/indexer.service.spec.ts b/backend/src/modules/blockchain/indexer.service.spec.ts index 8d7545087..e392e59c5 100644 --- a/backend/src/modules/blockchain/indexer.service.spec.ts +++ b/backend/src/modules/blockchain/indexer.service.spec.ts @@ -8,6 +8,7 @@ import { DeadLetterEvent } from './entities/dead-letter-event.entity'; import { SavingsProduct } from '../savings/entities/savings-product.entity'; import { StellarService } from './stellar.service'; import { DepositHandler } from './event-handlers/deposit.handler'; +import { WithdrawHandler } from './event-handlers/withdraw.handler'; import { YieldHandler } from './event-handlers/yield.handler'; describe('IndexerService', () => { @@ -17,6 +18,7 @@ describe('IndexerService', () => { let savingsProductRepo: any; let deadLetterRepo: any; let depositHandler: any; + let withdrawHandler: any; let yieldHandler: any; const mockIndexerState = { @@ -56,6 +58,7 @@ describe('IndexerService', () => { } as any; depositHandler = { handle: jest.fn().mockResolvedValue(true) }; + withdrawHandler = { handle: jest.fn().mockResolvedValue(false) }; yieldHandler = { handle: jest.fn().mockResolvedValue(false) }; const module: TestingModule = await Test.createTestingModule({ @@ -63,10 +66,20 @@ describe('IndexerService', () => { IndexerService, { provide: ConfigService, useValue: { get: jest.fn() } }, { provide: StellarService, useValue: stellarService }, - { provide: getRepositoryToken(IndexerState), useValue: indexerStateRepo }, - { provide: getRepositoryToken(DeadLetterEvent), useValue: deadLetterRepo }, - { provide: getRepositoryToken(SavingsProduct), useValue: savingsProductRepo }, + { + provide: getRepositoryToken(IndexerState), + useValue: indexerStateRepo, + }, + { + provide: getRepositoryToken(DeadLetterEvent), + useValue: deadLetterRepo, + }, + { + provide: getRepositoryToken(SavingsProduct), + useValue: savingsProductRepo, + }, { provide: DepositHandler, useValue: depositHandler }, + { provide: WithdrawHandler, useValue: withdrawHandler }, { provide: YieldHandler, useValue: yieldHandler }, ], }).compile(); @@ -87,7 +100,9 @@ describe('IndexerService', () => { it('should initialize state and load contract IDs', async () => { await service.onModuleInit(); expect(indexerStateRepo.findOne).toHaveBeenCalled(); - expect(savingsProductRepo.find).toHaveBeenCalledWith({ where: { isActive: true } }); + expect(savingsProductRepo.find).toHaveBeenCalledWith({ + where: { isActive: true }, + }); expect(service.getMonitoredContracts()).toContain('CC1'); expect(service.getMonitoredContracts()).toContain('CC2'); }); @@ -100,13 +115,22 @@ describe('IndexerService', () => { it('should fetch and process events successfully', async () => { const mockEvents = [ - { id: '1', ledger: '101', topic: ['deposit'], value: '100', txHash: 'hash1' }, + { + id: '1', + ledger: '101', + topic: ['deposit'], + value: '100', + txHash: 'hash1', + }, ]; (stellarService.getEvents as jest.Mock).mockResolvedValue(mockEvents); await service.runIndexerCycle(); - expect(stellarService.getEvents).toHaveBeenCalledWith(101, ['CC1', 'CC2']); + expect(stellarService.getEvents).toHaveBeenCalledWith(101, [ + 'CC1', + 'CC2', + ]); expect(depositHandler.handle).toHaveBeenCalled(); expect(indexerStateRepo.save).toHaveBeenCalled(); expect(service.getIndexerState()?.lastProcessedLedger).toBe(101); @@ -114,7 +138,13 @@ describe('IndexerService', () => { it('should handle failed events by logging to dead letter queue', async () => { const mockEvents = [ - { id: '1', ledger: '101', topic: ['deposit'], value: 'fail', txHash: 'hash1' }, + { + id: '1', + ledger: '101', + topic: ['deposit'], + value: 'fail', + txHash: 'hash1', + }, ]; (stellarService.getEvents as jest.Mock).mockResolvedValue(mockEvents); depositHandler.handle.mockRejectedValue(new Error('Processing failed')); diff --git a/backend/src/modules/blockchain/indexer.service.ts b/backend/src/modules/blockchain/indexer.service.ts index dfdb5327e..d1cc18aa9 100644 --- a/backend/src/modules/blockchain/indexer.service.ts +++ b/backend/src/modules/blockchain/indexer.service.ts @@ -7,6 +7,7 @@ import { rpc } from '@stellar/stellar-sdk'; import { DeadLetterEvent } from './entities/dead-letter-event.entity'; import { IndexerState } from './entities/indexer-state.entity'; import { DepositHandler } from './event-handlers/deposit.handler'; +import { WithdrawHandler } from './event-handlers/withdraw.handler'; import { YieldHandler } from './event-handlers/yield.handler'; import { StellarService } from './stellar.service'; import { SavingsProduct } from '../savings/entities/savings-product.entity'; @@ -43,6 +44,7 @@ export class IndexerService implements OnModuleInit { @InjectRepository(SavingsProduct) private readonly savingsProductRepo: Repository, private readonly depositHandler: DepositHandler, + private readonly withdrawHandler: WithdrawHandler, private readonly yieldHandler: YieldHandler, ) {} @@ -156,7 +158,7 @@ export class IndexerService implements OnModuleInit { } catch (err) { const msg = (err as Error).message; this.logger.error( - `Failed to process event ${event.id} from ledger ${event.ledger}: ${msg}`, + `FAILURE at Ledger ${event.ledger}: Processing of event ${event.id} crashed. JSON: ${JSON.stringify(event)}. Error: ${msg}`, ); await this.dlqRepo.save( @@ -173,6 +175,7 @@ export class IndexerService implements OnModuleInit { private async handleEvent(event: SorobanEvent): Promise { if (await this.depositHandler.handle(event)) return; + if (await this.withdrawHandler.handle(event)) return; if (await this.yieldHandler.handle(event)) return; this.logger.debug(`Unhandled event: ${JSON.stringify(event.topic)}`);