Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/modules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
10 changes: 8 additions & 2 deletions src/modules/keys/key-dynamic-fee.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface DynamicFeeResult {
components: {
baseBps: number;
volumeTierDiscountBps: number;
discountTier?: string;
protocolBps: number;
royaltyBps: number;
};
Expand Down Expand Up @@ -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();
Expand All @@ -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;
}
}
}

Expand Down Expand Up @@ -138,6 +143,7 @@ export async function getDynamicFeeRate(
components: {
baseBps: baseFeeBps,
volumeTierDiscountBps: volumeTierDiscountBps,
discountTier,
protocolBps: protocolFeeBps,
royaltyBps: royaltyBps,
},
Expand Down
44 changes: 44 additions & 0 deletions src/modules/traders/trader-discount.routes.ts
Original file line number Diff line number Diff line change
@@ -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;
100 changes: 100 additions & 0 deletions src/modules/traders/trader-discount.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import {
getTraderDiscount,
indexVolumeDiscountTierChangedEvent,
resetTraderDiscountStateForTests,
TRADER_DISCOUNT_CACHE_TTL_SECONDS,
} from './trader-discount.service';

const mockCache = new Map<string, { value: any; ttl: number }>();

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<Promise<bigint>, [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);
});
});
135 changes: 135 additions & 0 deletions src/modules/traders/trader-discount.service.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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<TraderDiscountInfo> {
const cacheKey = discountCacheKey(wallet);
const cached = await cacheGetJson<Omit<TraderDiscountInfo, 'cached'>>(
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,
};
}
Loading