From 5ce00ec6cd7690fdeb55e792b11912bc47fcc771 Mon Sep 17 00:00:00 2001 From: Youssef Dakkak Date: Fri, 16 Oct 2026 17:34:49 +0000 Subject: [PATCH] feat(traders): implement fee discount tier indexing and discount lookup API (#1030) Co-authored-by: Cursor --- src/modules/index.ts | 2 + src/modules/keys/key-dynamic-fee.service.ts | 10 +- src/modules/traders/trader-discount.routes.ts | 44 ++++++ .../traders/trader-discount.service.test.ts | 100 +++++++++++++ .../traders/trader-discount.service.ts | 135 ++++++++++++++++++ 5 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 src/modules/traders/trader-discount.routes.ts create mode 100644 src/modules/traders/trader-discount.service.test.ts create mode 100644 src/modules/traders/trader-discount.service.ts diff --git a/src/modules/index.ts b/src/modules/index.ts index b4968bb..adc254c 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -44,6 +44,7 @@ import factoryRouter from './factory/factory.routes'; import lpRouter from './lp/lp.routes'; import auctionRouter from './auctions/auction.routes'; import holdersRouter from './dividends/holders.routes'; +import tradersRouter from './traders/trader-discount.routes'; import { BASE as CREATORS_BASE } from '../constants/creator.constants'; const router = Router(); @@ -113,5 +114,6 @@ router.use('/factory', routeBodySizeLimit('default'), factoryRouter); router.use('/lp', routeBodySizeLimit('default'), lpRouter); router.use('/auctions', routeBodySizeLimit('default'), auctionRouter); router.use('/holders', routeBodySizeLimit('default'), holdersRouter); +router.use('/traders', routeBodySizeLimit('default'), tradersRouter); export default router; diff --git a/src/modules/keys/key-dynamic-fee.service.ts b/src/modules/keys/key-dynamic-fee.service.ts index da7bcd3..4d16d5a 100644 --- a/src/modules/keys/key-dynamic-fee.service.ts +++ b/src/modules/keys/key-dynamic-fee.service.ts @@ -14,6 +14,7 @@ export interface DynamicFeeResult { components: { baseBps: number; volumeTierDiscountBps: number; + discountTier?: string; protocolBps: number; royaltyBps: number; }; @@ -88,6 +89,7 @@ export async function getDynamicFeeRate( // 3. Protocol Fee & Volume Tier Discount let protocolFeeBps = baseFeeBps; + let discountTier = 'TIER_1'; if (wallet) { const volume30d = await computeWallet30dVolume(wallet); const tiers = await loadFeeTierConfig(); @@ -97,8 +99,11 @@ export async function getDynamicFeeRate( .filter(t => BigInt(t.volumeThreshold) <= volume30d) .sort((a, b) => Number(BigInt(b.volumeThreshold) - BigInt(a.volumeThreshold)))[0]; - if (applicableTier && applicableTier.feeBps < baseFeeBps) { - protocolFeeBps = applicableTier.feeBps; + if (applicableTier) { + discountTier = applicableTier.label; + if (applicableTier.feeBps < baseFeeBps) { + protocolFeeBps = applicableTier.feeBps; + } } } @@ -138,6 +143,7 @@ export async function getDynamicFeeRate( components: { baseBps: baseFeeBps, volumeTierDiscountBps: volumeTierDiscountBps, + discountTier, protocolBps: protocolFeeBps, royaltyBps: royaltyBps, }, diff --git a/src/modules/traders/trader-discount.routes.ts b/src/modules/traders/trader-discount.routes.ts new file mode 100644 index 0000000..c035707 --- /dev/null +++ b/src/modules/traders/trader-discount.routes.ts @@ -0,0 +1,44 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { StellarAddressSchema } from '../wallet/wallet.schemas'; +import { getTraderDiscount } from './trader-discount.service'; +import { + sendSuccess, + sendValidationError, + zodIssuesToDetails, +} from '../../utils/api-response.utils'; + +const traderParamsSchema = z.object({ + wallet: StellarAddressSchema, +}); + +const tradersRouter = Router(); + +/** + * GET /api/v1/traders/:wallet/discount + * Returns the current volume discount tier, discount bps, and rolling 30d volume + * for any wallet (cached with a 5-minute TTL per wallet). + */ +tradersRouter.get( + '/:wallet/discount', + async (req: Request, res: Response, next: NextFunction) => { + const parsed = traderParamsSchema.safeParse(req.params); + if (!parsed.success) { + sendValidationError( + res, + 'Invalid trader wallet address', + zodIssuesToDetails(parsed.error.issues) + ); + return; + } + + try { + const discount = await getTraderDiscount(parsed.data.wallet); + sendSuccess(res, discount); + } catch (error) { + next(error); + } + } +); + +export default tradersRouter; diff --git a/src/modules/traders/trader-discount.service.test.ts b/src/modules/traders/trader-discount.service.test.ts new file mode 100644 index 0000000..b039296 --- /dev/null +++ b/src/modules/traders/trader-discount.service.test.ts @@ -0,0 +1,100 @@ +import { + getTraderDiscount, + indexVolumeDiscountTierChangedEvent, + resetTraderDiscountStateForTests, + TRADER_DISCOUNT_CACHE_TTL_SECONDS, +} from './trader-discount.service'; + +const mockCache = new Map(); + +jest.mock('../../utils/redis.utils', () => ({ + cacheGetJson: jest.fn(async (key: string) => + mockCache.has(key) ? mockCache.get(key)!.value : null + ), + cacheSetJson: jest.fn(async (key: string, value: any, ttl: number) => { + mockCache.set(key, { value, ttl }); + }), + cacheInvalidate: jest.fn(async (key: string) => { + mockCache.delete(key); + }), +})); + +const mockComputeWallet30dVolume = jest.fn, [string]>(); +jest.mock('../../utils/trading-volume.utils', () => ({ + computeWallet30dVolume: (wallet: string) => + mockComputeWallet30dVolume(wallet), +})); + +jest.mock('../protocol/fee-tier.service', () => ({ + loadFeeTierConfig: jest.fn(async () => [ + { volumeThreshold: 0, feeBps: 500, label: 'TIER_1' }, + { volumeThreshold: 1_000_000_000_000, feeBps: 400, label: 'TIER_2' }, + { volumeThreshold: 5_000_000_000_000, feeBps: 300, label: 'TIER_3' }, + ]), +})); + +jest.mock('../../utils/logger.utils', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +const WALLET = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + +describe('fee discount tier indexing & lookup (#1030)', () => { + beforeEach(() => { + mockCache.clear(); + resetTraderDiscountStateForTests(); + jest.clearAllMocks(); + }); + + it('computes 30d rolling volume discount tier and caches with 5-minute TTL', async () => { + mockComputeWallet30dVolume.mockResolvedValue(2_500_000_000_000n); + + const first = await getTraderDiscount(WALLET); + expect(first).toEqual({ + wallet: WALLET, + tier: 'TIER_2', + discountBps: 100, + volume30dStroops: '2500000000000', + cached: false, + }); + + const cachedEntry = mockCache.get(`trader:discount:${WALLET}`); + expect(cachedEntry?.ttl).toBe(TRADER_DISCOUNT_CACHE_TTL_SECONDS); + + const second = await getTraderDiscount(WALLET); + expect(second.cached).toBe(true); + expect(mockComputeWallet30dVolume).toHaveBeenCalledTimes(1); + }); + + it('indexes VolumeDiscountTierChanged events without duplication and refreshes cache', async () => { + mockComputeWallet30dVolume.mockResolvedValue(0n); + + await getTraderDiscount(WALLET); + expect(mockCache.has(`trader:discount:${WALLET}`)).toBe(true); + + const evt = { + txHash: 'tx_abc', + ledger: 12345, + eventIndex: 0, + wallet: WALLET, + tier: 'TIER_3', + discountBps: 200, + }; + + const res1 = await indexVolumeDiscountTierChangedEvent(evt); + expect(res1).toEqual({ indexed: true, duplicate: false }); + expect(mockCache.has(`trader:discount:${WALLET}`)).toBe(false); + + const res2 = await indexVolumeDiscountTierChangedEvent(evt); + expect(res2).toEqual({ indexed: false, duplicate: true }); + + const updated = await getTraderDiscount(WALLET); + expect(updated.tier).toBe('TIER_3'); + expect(updated.discountBps).toBe(200); + }); +}); diff --git a/src/modules/traders/trader-discount.service.ts b/src/modules/traders/trader-discount.service.ts new file mode 100644 index 0000000..29f0dc7 --- /dev/null +++ b/src/modules/traders/trader-discount.service.ts @@ -0,0 +1,135 @@ +import { + cacheGetJson, + cacheSetJson, + cacheInvalidate, +} from '../../utils/redis.utils'; +import { computeWallet30dVolume } from '../../utils/trading-volume.utils'; +import { loadFeeTierConfig } from '../protocol/fee-tier.service'; +import { logger } from '../../utils/logger.utils'; + +export const TRADER_DISCOUNT_CACHE_TTL_SECONDS = 300; // 5 minutes +export const DEFAULT_BASE_PROTOCOL_FEE_BPS = 500; + +export interface VolumeDiscountTierChangedEvent { + txHash: string; + ledger: number; + eventIndex?: number; + wallet: string; + tier: string; + discountBps: number; + volume30dStroops?: string; +} + +export interface TraderDiscountInfo { + wallet: string; + tier: string; + discountBps: number; + volume30dStroops: string; + cached: boolean; +} + +const indexedEventKeys = new Set(); +const indexedWalletTierOverrides = new Map< + string, + { tier: string; discountBps: number; ledger: number } +>(); + +function discountCacheKey(wallet: string): string { + return `trader:discount:${wallet}`; +} + +export function resetTraderDiscountStateForTests(): void { + indexedEventKeys.clear(); + indexedWalletTierOverrides.clear(); +} + +/** + * Indexes `VolumeDiscountTierChanged` contract events without duplication + * and invalidates the affected wallet's 5-minute discount cache. + */ +export async function indexVolumeDiscountTierChangedEvent( + event: VolumeDiscountTierChangedEvent +): Promise<{ indexed: boolean; duplicate: boolean }> { + const dedupKey = `${event.txHash}:${event.ledger}:${event.eventIndex ?? 0}:${event.wallet}`; + if (indexedEventKeys.has(dedupKey)) { + return { indexed: false, duplicate: true }; + } + + indexedEventKeys.add(dedupKey); + indexedWalletTierOverrides.set(event.wallet, { + tier: event.tier, + discountBps: event.discountBps, + ledger: event.ledger, + }); + + await cacheInvalidate(discountCacheKey(event.wallet)); + + logger.info( + { + wallet: event.wallet, + tier: event.tier, + discountBps: event.discountBps, + ledger: event.ledger, + }, + 'Indexed VolumeDiscountTierChanged event' + ); + + return { indexed: true, duplicate: false }; +} + +/** + * Computes or retrieves the current fee discount tier, discount bps, and + * rolling 30-day trading volume for a wallet, cached with a 5-minute TTL. + */ +export async function getTraderDiscount( + wallet: string +): Promise { + const cacheKey = discountCacheKey(wallet); + const cached = await cacheGetJson>( + cacheKey + ); + if (cached !== null) { + return { + ...cached, + cached: true, + }; + } + + const volume30d = await computeWallet30dVolume(wallet); + const tiers = await loadFeeTierConfig(); + + const sortedDesc = [...tiers].sort( + (a, b) => b.volumeThreshold - a.volumeThreshold + ); + const matchedTier = + sortedDesc.find(t => volume30d >= BigInt(t.volumeThreshold)) ?? + tiers[0] ?? { + volumeThreshold: 0, + feeBps: DEFAULT_BASE_PROTOCOL_FEE_BPS, + label: 'TIER_1', + }; + + const baseFeeBps = tiers[0]?.feeBps ?? DEFAULT_BASE_PROTOCOL_FEE_BPS; + let tierLabel = matchedTier.label; + let discountBps = Math.max(0, baseFeeBps - matchedTier.feeBps); + + const override = indexedWalletTierOverrides.get(wallet); + if (override && override.discountBps > discountBps) { + tierLabel = override.tier; + discountBps = override.discountBps; + } + + const payload = { + wallet, + tier: tierLabel, + discountBps, + volume30dStroops: volume30d.toString(), + }; + + await cacheSetJson(cacheKey, payload, TRADER_DISCOUNT_CACHE_TTL_SECONDS); + + return { + ...payload, + cached: false, + }; +}