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) }; } }