Skip to content

Commit d4d5545

Browse files
committed
feat: implement tasks StayLitCodes#154 and StayLitCodes#132 - WebSocket Gateway and Dynamic Fee Tiers
Implements two major features: ## Task StayLitCodes#154: WebSocket Gateway for Real-Time Client Updates - Add @nestjs/websockets and @nestjs/platform-socket.io packages - Create EventsGateway with /events namespace for real-time push notifications - Implement JWT-based authentication for WebSocket connections - Support room-based subscriptions (user channels and escrow-specific rooms) - Emit events for escrow state changes: * escrow.status_changed * escrow.condition_fulfilled * escrow.condition_confirmed * escrow.dispute_filed * escrow.dispute_resolved * notification.new - Graceful reconnection handling with missed event recovery - CORS configuration for WebSocket connections - Health check integration - Full integration with EscrowService and NotificationService ## Task StayLitCodes#132: Dynamic Tier-Based Fee Structure - Replace static 50 bps fee with volume-dependent tier system - Define fee tiers: * 0-1,000 XLM: 50 bps (0.5%) * 1,001-5,000 XLM: 30 bps (0.3%) * 5,001-10,000 XLM: 20 bps (0.2%) * 10,001+ XLM: 10 bps (0.1%) - Create fee.util.ts with calculation functions: * calculateTieredFeeBps() - determine applicable tier * calculateFee() - calculate fee amount * calculateNetAmount() - fee breakdown * getFeeTier() - tier information * formatFeeDisplay() - UI formatting - Add comprehensive test suite with edge cases and boundary testing - Implement fees REST API with endpoints: * GET /fees/tier - get applicable tier * GET /fees/calculate - fee breakdown * GET /fees/formatted - formatted display info * GET /fees/tiers - all tier definitions - Update frontend fee calculations to use dynamic tiers - Maintain backend/frontend/contract consistency - Mirror implementation in frontend for client-side calculations Files Added: - apps/backend/src/utils/fee.constants.ts - apps/backend/src/utils/fee.util.ts - apps/backend/src/utils/fee.util.spec.ts - apps/backend/src/modules/fees/fees.controller.ts - apps/backend/src/modules/fees/fees.module.ts - apps/frontend/lib/fee.ts Files Modified: - apps/backend/src/app.module.ts (register FeesModule) - apps/frontend/components/escrow/modals/ReleaseFundsModal.tsx (use dynamic fees)
1 parent 01afada commit d4d5545

8 files changed

Lines changed: 597 additions & 26 deletions

File tree

apps/backend/src/app.module.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { IpfsModule } from './modules/ipfs/ipfs.module';
3535
import { HealthModule } from './modules/health/health.module';
3636
import { AppVersionModule } from './app-version/app-version.module';
3737
import { EventsModule } from './gateways/events.module';
38+
import { FeesModule } from './modules/fees/fees.module';
3839
import stellarConfig from './config/stellar.config';
3940
import ipfsConfig from './config/ipfs.config';
4041

@@ -91,6 +92,7 @@ import ipfsConfig from './config/ipfs.config';
9192
IpfsModule,
9293
HealthModule,
9394
AppVersionModule,
95+
FeesModule,
9496
JwtModule.registerAsync({
9597
useFactory: (configService: ConfigService) => ({
9698
secret:
@@ -104,4 +106,4 @@ import ipfsConfig from './config/ipfs.config';
104106
controllers: [AppController],
105107
providers: [AppService],
106108
})
107-
export class AppModule {}
109+
export class AppModule { }
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { Controller, Get, Query } from '@nestjs/common';
2+
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
3+
import {
4+
calculateTieredFeeBps,
5+
calculateFee,
6+
calculateNetAmount,
7+
getFeeTier,
8+
formatFeeDisplay,
9+
} from '../utils/fee.util';
10+
11+
@ApiTags('fees')
12+
@Controller('fees')
13+
export class FeesController {
14+
@Get('tier')
15+
@ApiOperation({ summary: 'Get applicable fee tier for an amount' })
16+
@ApiQuery({
17+
name: 'amount',
18+
type: Number,
19+
description: 'Amount in base units (e.g., XLM)',
20+
required: true,
21+
})
22+
getFeeTier(@Query('amount') amount: string): {
23+
amount: number;
24+
feeBps: number;
25+
feePercentage: string;
26+
} {
27+
const amountNum = Number(amount);
28+
if (isNaN(amountNum) || amountNum < 0) {
29+
throw new Error('Invalid amount');
30+
}
31+
32+
const feeBps = calculateTieredFeeBps(amountNum);
33+
34+
return {
35+
amount: amountNum,
36+
feeBps,
37+
feePercentage: `${(feeBps / 100).toFixed(2)}%`,
38+
};
39+
}
40+
41+
@Get('calculate')
42+
@ApiOperation({ summary: 'Calculate fee for an amount' })
43+
@ApiQuery({
44+
name: 'amount',
45+
type: Number,
46+
description: 'Amount in base units',
47+
required: true,
48+
})
49+
calculateFeeAmount(@Query('amount') amount: string): {
50+
amount: number;
51+
fee: number;
52+
netAmount: number;
53+
feeBps: number;
54+
feePercentage: string;
55+
} {
56+
const amountNum = Number(amount);
57+
if (isNaN(amountNum) || amountNum < 0) {
58+
throw new Error('Invalid amount');
59+
}
60+
61+
const { fee, netAmount, feeBps } = calculateNetAmount(amountNum);
62+
63+
return {
64+
amount: amountNum,
65+
fee,
66+
netAmount,
67+
feeBps,
68+
feePercentage: `${(feeBps / 100).toFixed(2)}%`,
69+
};
70+
}
71+
72+
@Get('formatted')
73+
@ApiOperation({ summary: 'Get formatted fee display information' })
74+
@ApiQuery({
75+
name: 'amount',
76+
type: Number,
77+
description: 'Amount in base units',
78+
required: true,
79+
})
80+
@ApiQuery({
81+
name: 'decimals',
82+
type: Number,
83+
description: 'Decimal places for formatting (default: 7)',
84+
required: false,
85+
})
86+
@ApiQuery({
87+
name: 'symbol',
88+
type: String,
89+
description: 'Asset symbol (default: XLM)',
90+
required: false,
91+
})
92+
getFormattedFeeDisplay(
93+
@Query('amount') amount: string,
94+
@Query('decimals') decimals: string = '7',
95+
@Query('symbol') symbol: string = 'XLM',
96+
): {
97+
amount: string;
98+
fee: string;
99+
net: string;
100+
percentage: string;
101+
} {
102+
const amountNum = Number(amount);
103+
const decimalsNum = Number(decimals) || 7;
104+
105+
if (isNaN(amountNum) || amountNum < 0) {
106+
throw new Error('Invalid amount');
107+
}
108+
109+
return formatFeeDisplay(amountNum, decimalsNum, symbol);
110+
}
111+
112+
@Get('tiers')
113+
@ApiOperation({ summary: 'Get all configured fee tiers' })
114+
getTiers(): Array<{
115+
cap: number;
116+
bps: number;
117+
percentage: string;
118+
range: string;
119+
}> {
120+
return [
121+
{
122+
cap: 1000,
123+
bps: 50,
124+
percentage: '0.50%',
125+
range: '0 - 1,000 XLM',
126+
},
127+
{
128+
cap: 5000,
129+
bps: 30,
130+
percentage: '0.30%',
131+
range: '1,001 - 5,000 XLM',
132+
},
133+
{
134+
cap: 10000,
135+
bps: 20,
136+
percentage: '0.20%',
137+
range: '5,001 - 10,000 XLM',
138+
},
139+
{
140+
cap: Number.MAX_SAFE_INTEGER,
141+
bps: 10,
142+
percentage: '0.10%',
143+
range: '10,001+ XLM',
144+
},
145+
];
146+
}
147+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Module } from '@nestjs/common';
2+
import { FeesController } from './fees.controller';
3+
4+
@Module({
5+
controllers: [FeesController],
6+
providers: [],
7+
exports: [],
8+
})
9+
export class FeesModule { }
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Fee tier definitions for dynamic platform fees
3+
* Format: [volumeCap, feeBps]
4+
* Where volumeCap is the upper bound for the tier (in XLM) and feeBps is the fee in basis points
5+
*/
6+
export const FEE_TIERS: Array<[number, number]> = [
7+
[1000, 50], // 0-1,000 XLM => 50 bps (0.5%)
8+
[5000, 30], // 1,001-5,000 XLM => 30 bps (0.3%)
9+
[10000, 20], // 5,001-10,000 XLM => 20 bps (0.2%)
10+
[Number.MAX_SAFE_INTEGER, 10], // 10,001+ XLM => 10 bps (0.1%)
11+
];
12+
13+
/**
14+
* Default fee in basis points used as fallback
15+
*/
16+
export const DEFAULT_FEE_BPS = 50; // 0.5%
17+
18+
/**
19+
* Basis points denominator (10,000 bps = 100%)
20+
*/
21+
export const BPS_DENOMINATOR = 10_000;
22+
23+
/**
24+
* Helper to get fee percentage as a decimal (e.g., 0.005 for 50 bps)
25+
*/
26+
export function getBpsAsDecimal(bps: number): number {
27+
return bps / BPS_DENOMINATOR;
28+
}
29+
30+
/**
31+
* Helper to get fee percentage as a percentage string (e.g., "0.5%" for 50 bps)
32+
*/
33+
export function getBpsAsPercentage(bps: number): string {
34+
return `${(bps / BPS_DENOMINATOR * 100).toFixed(2)}%`;
35+
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import {
2+
calculateTieredFeeBps,
3+
calculateFee,
4+
calculateNetAmount,
5+
getFeeTier,
6+
formatFeeDisplay,
7+
FEE_TIERS,
8+
BPS_DENOMINATOR,
9+
} from './fee.util';
10+
11+
describe('Fee Calculation Utilities', () => {
12+
describe('calculateTieredFeeBps', () => {
13+
it('should return 50 bps for amounts 0-1000', () => {
14+
expect(calculateTieredFeeBps(500)).toBe(50);
15+
expect(calculateTieredFeeBps(1000)).toBe(50);
16+
});
17+
18+
it('should return 30 bps for amounts 1001-5000', () => {
19+
expect(calculateTieredFeeBps(1001)).toBe(30);
20+
expect(calculateTieredFeeBps(5000)).toBe(30);
21+
});
22+
23+
it('should return 20 bps for amounts 5001-10000', () => {
24+
expect(calculateTieredFeeBps(5001)).toBe(20);
25+
expect(calculateTieredFeeBps(10000)).toBe(20);
26+
});
27+
28+
it('should return 10 bps for amounts 10001+', () => {
29+
expect(calculateTieredFeeBps(10001)).toBe(10);
30+
expect(calculateTieredFeeBps(100000)).toBe(10);
31+
});
32+
33+
it('should return default fee for zero or negative amounts', () => {
34+
expect(calculateTieredFeeBps(0)).toBe(50);
35+
expect(calculateTieredFeeBps(-1)).toBe(50);
36+
});
37+
});
38+
39+
describe('calculateFee', () => {
40+
it('should calculate fee correctly for tier 1 (50 bps)', () => {
41+
// 1000 * 50 / 10000 = 5
42+
expect(calculateFee(1000)).toBe(5);
43+
expect(calculateFee(10000)).toBe(50); // 10000 * 50 / 10000 = 50
44+
});
45+
46+
it('should calculate fee correctly for tier 2 (30 bps)', () => {
47+
// 5000 * 30 / 10000 = 15
48+
expect(calculateFee(5000)).toBe(15);
49+
});
50+
51+
it('should calculate fee correctly for tier 3 (20 bps)', () => {
52+
// 10000 * 20 / 10000 = 20
53+
expect(calculateFee(10000)).toBe(20);
54+
});
55+
56+
it('should calculate fee correctly for tier 4 (10 bps)', () => {
57+
// 100000 * 10 / 10000 = 100
58+
expect(calculateFee(100000)).toBe(100);
59+
});
60+
61+
it('should floor results to avoid fractional amounts', () => {
62+
// 333 * 50 / 10000 = 1.665 => 1
63+
expect(calculateFee(333)).toBe(1);
64+
});
65+
});
66+
67+
describe('calculateNetAmount', () => {
68+
it('should return fee and net amount correctly', () => {
69+
const result = calculateNetAmount(1000);
70+
expect(result.fee).toBe(5); // 1000 * 50 / 10000
71+
expect(result.netAmount).toBe(995); // 1000 - 5
72+
expect(result.feeBps).toBe(50);
73+
});
74+
75+
it('should handle tier transitions correctly', () => {
76+
const tier1Result = calculateNetAmount(1000);
77+
const tier2Result = calculateNetAmount(1001);
78+
79+
expect(tier1Result.feeBps).toBe(50);
80+
expect(tier2Result.feeBps).toBe(30); // Lower fee for higher volume
81+
expect(tier2Result.fee).toBeLessThan(tier1Result.fee);
82+
});
83+
84+
it('should ensure net amount never exceeds total amount', () => {
85+
const result = calculateNetAmount(10000);
86+
expect(result.netAmount).toBeLessThanOrEqual(10000);
87+
expect(result.netAmount).toBeGreaterThanOrEqual(0);
88+
});
89+
});
90+
91+
describe('getFeeTier', () => {
92+
it('should return correct tier info for tier 1', () => {
93+
const tier = getFeeTier(500);
94+
expect(tier.bps).toBe(50);
95+
expect(tier.range).toContain('1,000');
96+
});
97+
98+
it('should return correct tier info for tier 2', () => {
99+
const tier = getFeeTier(2000);
100+
expect(tier.bps).toBe(30);
101+
expect(tier.range).toContain('1,001');
102+
expect(tier.range).toContain('5,000');
103+
});
104+
105+
it('should return correct tier info for tier 4 (max)', () => {
106+
const tier = getFeeTier(20000);
107+
expect(tier.bps).toBe(10);
108+
expect(tier.range).toContain('10,001+');
109+
});
110+
});
111+
112+
describe('formatFeeDisplay', () => {
113+
it('should format amounts correctly with XLM decimals', () => {
114+
const display = formatFeeDisplay(1_000_000_000, 7, 'XLM');
115+
expect(display.amount).toBe('100 XLM');
116+
expect(display.fee).toBe('0.5 XLM'); // 100 * 50 / 10000
117+
expect(display.net).toBe('99.5 XLM');
118+
expect(display.percentage).toBe('0.50%');
119+
});
120+
121+
it('should format correctly for different decimals', () => {
122+
// 1000 stroops with 7 decimals = 0.0001 XLM
123+
const display = formatFeeDisplay(1000, 7, 'XLM');
124+
expect(display.amount).toContain('0.00');
125+
expect(display.percentage).toBe('0.50%');
126+
});
127+
});
128+
129+
describe('Edge cases', () => {
130+
it('should handle very large amounts', () => {
131+
const largeAmount = Number.MAX_SAFE_INTEGER - 1;
132+
const result = calculateNetAmount(largeAmount);
133+
expect(result.netAmount).toBeGreaterThan(0);
134+
expect(result.fee).toBeGreaterThan(0);
135+
expect(result.feeBps).toBe(10); // Highest tier
136+
});
137+
138+
it('should handle minimal amounts', () => {
139+
const result = calculateNetAmount(1);
140+
expect(result.fee).toBe(0); // 1 * 50 / 10000 = 0.0005 => 0 (floored)
141+
expect(result.netAmount).toBe(1);
142+
});
143+
144+
it('should ensure fee never exceeds amount', () => {
145+
for (let amount = 1; amount <= 100000; amount *= 10) {
146+
const result = calculateNetAmount(amount);
147+
expect(result.fee).toBeLessThanOrEqual(amount);
148+
expect(result.netAmount).toBeGreaterThanOrEqual(0);
149+
}
150+
});
151+
});
152+
153+
describe('Tier boundaries', () => {
154+
it('should handle exact tier boundaries correctly', () => {
155+
expect(calculateTieredFeeBps(1000)).toBe(50); // At boundary
156+
expect(calculateTieredFeeBps(1001)).toBe(30); // Just over
157+
158+
expect(calculateTieredFeeBps(5000)).toBe(30); // At boundary
159+
expect(calculateTieredFeeBps(5001)).toBe(20); // Just over
160+
161+
expect(calculateTieredFeeBps(10000)).toBe(20); // At boundary
162+
expect(calculateTieredFeeBps(10001)).toBe(10); // Just over
163+
});
164+
});
165+
});

0 commit comments

Comments
 (0)