Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions backend/src/modules/blockchain/event-handlers/withdraw.handler.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(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();
});
});
});
37 changes: 32 additions & 5 deletions backend/src/modules/blockchain/event-handlers/withdraw.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { DataSource } from 'typeorm';
import { scValToNative, xdr } from '@stellar/stellar-sdk';
import {
LedgerTransaction,
LedgerTransactionStatus,
LedgerTransactionType,
} from '../entities/transaction.entity';
import {
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -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'
Expand Down
Loading