From 010391a09ca0166436c3b847663ab6ab6fdbe7f8 Mon Sep 17 00:00:00 2001 From: muazu Date: Sun, 18 Oct 2026 00:10:04 +0100 Subject: [PATCH] feat: expose creator key swap fee tier and history endpoints - Add GET /keys/:keyId/fee returning current_fee_bps, volume_bracket, and rolling_24h_volume with 60s cache TTL - Read fee basis points from dynamic fee contract get_current_fee_bps with volume tier fallback - Add GET /keys/:keyId/fee/history returning fee tier change history with timestamps - Index FeeAdjusted contract events into key_fee_history and invalidate key fee cache - Handle 404 for unknown key IDs - Add unit and route test suites covering swap fee tier, cache TTL, event indexing, and history --- prisma/schema/key-fee-history.prisma | 21 ++ .../migration.sql | 25 ++ src/app.ts | 2 + src/config.schema.ts | 3 + .../indexer/key-fee-indexer.service.test.ts | 132 +++++++++++ .../indexer/key-fee-indexer.service.ts | 142 ++++++++++++ src/modules/keys/key-swap-fee-contract.ts | 82 +++++++ src/modules/keys/key-swap-fee.routes.test.ts | 140 +++++++++++ src/modules/keys/key-swap-fee.service.test.ts | 217 ++++++++++++++++++ src/modules/keys/key-swap-fee.service.ts | 171 ++++++++++++++ src/modules/keys/keys.routes.ts | 89 +++++-- 11 files changed, 1003 insertions(+), 21 deletions(-) create mode 100644 prisma/schema/key-fee-history.prisma create mode 100644 prisma/schema/migrations/20261017000000_add_key_fee_history/migration.sql create mode 100644 src/modules/indexer/key-fee-indexer.service.test.ts create mode 100644 src/modules/indexer/key-fee-indexer.service.ts create mode 100644 src/modules/keys/key-swap-fee-contract.ts create mode 100644 src/modules/keys/key-swap-fee.routes.test.ts create mode 100644 src/modules/keys/key-swap-fee.service.test.ts create mode 100644 src/modules/keys/key-swap-fee.service.ts diff --git a/prisma/schema/key-fee-history.prisma b/prisma/schema/key-fee-history.prisma new file mode 100644 index 00000000..49f2e78f --- /dev/null +++ b/prisma/schema/key-fee-history.prisma @@ -0,0 +1,21 @@ +// prisma/schema/key-fee-history.prisma +// Fee tier change history indexed from FeeAdjusted contract events. + +model KeyFeeHistory { + id String @id @default(cuid()) + keyId String @map("key_id") + feeBps Int @map("fee_bps") + previousFeeBps Int? @map("previous_fee_bps") + volumeBracket String? @map("volume_bracket") + rolling24hVolume String? @map("rolling_24h_volume") + ledger Int + txHash String @map("tx_hash") + eventIndex Int @map("event_index") + timestamp DateTime + createdAt DateTime @default(now()) @map("created_at") + + @@unique([txHash, eventIndex]) + @@index([keyId, timestamp(sort: Desc)]) + @@index([keyId]) + @@map("key_fee_history") +} diff --git a/prisma/schema/migrations/20261017000000_add_key_fee_history/migration.sql b/prisma/schema/migrations/20261017000000_add_key_fee_history/migration.sql new file mode 100644 index 00000000..4a908b96 --- /dev/null +++ b/prisma/schema/migrations/20261017000000_add_key_fee_history/migration.sql @@ -0,0 +1,25 @@ +-- Fee tier change history indexed from FeeAdjusted contract events. +CREATE TABLE "key_fee_history" ( + "id" TEXT NOT NULL, + "key_id" TEXT NOT NULL, + "fee_bps" INTEGER NOT NULL, + "previous_fee_bps" INTEGER, + "volume_bracket" TEXT, + "rolling_24h_volume" TEXT, + "ledger" INTEGER NOT NULL, + "tx_hash" TEXT NOT NULL, + "event_index" INTEGER NOT NULL, + "timestamp" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "key_fee_history_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "key_fee_history_tx_hash_event_index_key" ON "key_fee_history"("tx_hash", "event_index"); + +-- CreateIndex +CREATE INDEX "key_fee_history_key_id_timestamp_idx" ON "key_fee_history"("key_id", "timestamp" DESC); + +-- CreateIndex +CREATE INDEX "key_fee_history_key_id_idx" ON "key_fee_history"("key_id"); diff --git a/src/app.ts b/src/app.ts index f9f2b857..ff762ada 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,6 +5,7 @@ import { errorHandler, notFoundHandler } from './middlewares/error.middleware'; import router from './modules/index'; import stakingRouter from './modules/staking/staking.routes'; import statusRouter from './modules/status/status.routes'; +import keysRouter from './modules/keys/keys.routes'; import { corsMiddleware } from './middlewares/cors.middleware'; import helmet from 'helmet'; import morgan from 'morgan'; @@ -99,6 +100,7 @@ app.get('/', (_, res: Response) => { app.use('/status', routeBodySizeLimit('default'), statusRouter); app.use('/api/v1', router); app.use('/staking', routeBodySizeLimit('default'), stakingRouter); +app.use('/keys', routeBodySizeLimit('default'), keysRouter); // Catches body-parse errors (including entity.too.large from the per-group // JSON parsers mounted inside router) — must come after the router since diff --git a/src/config.schema.ts b/src/config.schema.ts index 2fd38014..6230fb0d 100644 --- a/src/config.schema.ts +++ b/src/config.schema.ts @@ -246,6 +246,9 @@ export const envSchema = z .positive() .default(300), + // Dynamic fee contract (#1055) + DYNAMIC_FEE_CONTRACT_ID: optionalNonEmptyString, + // Ownership snapshot cleanup job OWNERSHIP_SNAPSHOT_TABLE_NAME: z .string() diff --git a/src/modules/indexer/key-fee-indexer.service.test.ts b/src/modules/indexer/key-fee-indexer.service.test.ts new file mode 100644 index 00000000..cedb0ead --- /dev/null +++ b/src/modules/indexer/key-fee-indexer.service.test.ts @@ -0,0 +1,132 @@ +// src/modules/indexer/key-fee-indexer.service.test.ts + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + creatorProfile: { + findFirst: jest.fn(), + }, + keyFeeHistory: { + upsert: jest.fn(), + }, + }, +})); + +jest.mock('../keys/key-swap-fee.service', () => ({ + invalidateKeyFeeCache: jest.fn(), +})); + +jest.mock('../../utils/logger.utils', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +import { prisma } from '../../utils/prisma.utils'; +import { invalidateKeyFeeCache } from '../keys/key-swap-fee.service'; +import { + processFeeAdjustedEvents, + FeeAdjustedChainEvent, +} from './key-fee-indexer.service'; + +const mockPrisma = prisma as unknown as { + creatorProfile: { findFirst: jest.Mock }; + keyFeeHistory: { upsert: jest.Mock }; +}; +const mockInvalidateCache = invalidateKeyFeeCache as jest.Mock; + +describe('key-fee-indexer.service', () => { + const KEY_ID = 'key-uuid-1'; + + beforeEach(() => { + jest.clearAllMocks(); + mockPrisma.creatorProfile.findFirst.mockResolvedValue({ + id: KEY_ID, + }); + mockPrisma.keyFeeHistory.upsert.mockResolvedValue({ + id: 'fee-hist-1', + }); + }); + + it('indexes FeeAdjusted event and invalidates cache for affected key', async () => { + const event: FeeAdjustedChainEvent = { + eventType: 'FeeAdjusted', + keyId: KEY_ID, + feeBps: 300, + previousFeeBps: 400, + volumeBracket: 'TIER_3', + rolling24hVolume: '5500000000', + ledger: 105, + txHash: '0x123456', + eventIndex: 1, + timestamp: '2026-10-17T12:00:00Z', + }; + + await processFeeAdjustedEvents([event]); + + expect(mockPrisma.creatorProfile.findFirst).toHaveBeenCalledWith({ + where: { OR: [{ id: KEY_ID }, { handle: KEY_ID }] }, + select: { id: true }, + }); + + expect(mockPrisma.keyFeeHistory.upsert).toHaveBeenCalledWith({ + where: { + txHash_eventIndex: { + txHash: '0x123456', + eventIndex: 1, + }, + }, + create: { + keyId: KEY_ID, + feeBps: 300, + previousFeeBps: 400, + volumeBracket: 'TIER_3', + rolling24hVolume: '5500000000', + ledger: 105, + txHash: '0x123456', + eventIndex: 1, + timestamp: new Date('2026-10-17T12:00:00Z'), + }, + update: { + feeBps: 300, + previousFeeBps: 400, + volumeBracket: 'TIER_3', + rolling24hVolume: '5500000000', + timestamp: new Date('2026-10-17T12:00:00Z'), + }, + }); + + expect(mockInvalidateCache).toHaveBeenCalledWith(KEY_ID); + }); + + it('skips non-FeeAdjusted events', async () => { + await processFeeAdjustedEvents([ + { + eventType: 'OTHER_EVENT' as any, + txHash: '0xabc', + eventIndex: 0, + } as any, + ]); + + expect(mockPrisma.keyFeeHistory.upsert).not.toHaveBeenCalled(); + expect(mockInvalidateCache).not.toHaveBeenCalled(); + }); + + it('skips events missing required fields', async () => { + await processFeeAdjustedEvents([ + { + eventType: 'FeeAdjusted', + keyId: KEY_ID, + // missing feeBps + txHash: '0xabc', + eventIndex: 0, + ledger: 100, + } as any, + ]); + + expect(mockPrisma.keyFeeHistory.upsert).not.toHaveBeenCalled(); + expect(mockInvalidateCache).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/indexer/key-fee-indexer.service.ts b/src/modules/indexer/key-fee-indexer.service.ts new file mode 100644 index 00000000..b67f9f6a --- /dev/null +++ b/src/modules/indexer/key-fee-indexer.service.ts @@ -0,0 +1,142 @@ +// src/modules/indexer/key-fee-indexer.service.ts +// Indexes FeeAdjusted contract events to track fee tier change history +// and invalidates the 60s swap fee cache for affected keys. + +import { prisma } from '../../utils/prisma.utils'; +import { logger } from '../../utils/logger.utils'; +import { + processIndexerChainEvents, + IndexerChainEvent, +} from '../../utils/indexer-event-processor.utils'; +import { invalidateKeyFeeCache } from '../keys/key-swap-fee.service'; + +export const FEE_ADJUSTED_EVENT_TYPES = [ + 'FeeAdjusted', + 'FEE_ADJUSTED', + 'fee_adjusted', +] as const; + +export interface FeeAdjustedChainEvent extends IndexerChainEvent { + eventType: 'FeeAdjusted' | 'FEE_ADJUSTED' | 'fee_adjusted'; + keyId: string; + feeBps: number | string; + previousFeeBps?: number | string | null; + volumeBracket?: string | null; + rolling24hVolume?: string | null; + ledger: number; + txHash: string; + eventIndex: number; + timestamp?: string | Date; +} + +export async function processFeeAdjustedEvents( + events: IndexerChainEvent[] +): Promise { + await processIndexerChainEvents(events, async event => { + const isFeeAdjusted = FEE_ADJUSTED_EVENT_TYPES.includes( + event.eventType as any + ); + + if (!isFeeAdjusted) { + return; + } + + const typedEvent = event as FeeAdjustedChainEvent; + + const requiredFields = [ + 'keyId', + 'feeBps', + 'ledger', + 'txHash', + 'eventIndex', + ]; + for (const field of requiredFields) { + const value = (typedEvent as any)[field]; + if (value === undefined || value === null || value === '') { + logger.warn( + { + eventId: `${event.txHash}:${event.eventIndex}`, + missingField: field, + }, + 'Skipping FeeAdjusted event due to missing required field' + ); + return; + } + } + + const feeBps = Number(typedEvent.feeBps); + if (!Number.isFinite(feeBps) || feeBps < 0) { + logger.warn( + { + eventId: `${event.txHash}:${event.eventIndex}`, + feeBps: typedEvent.feeBps, + }, + 'Skipping FeeAdjusted event with invalid feeBps' + ); + return; + } + + const previousFeeBps = + typedEvent.previousFeeBps !== undefined && + typedEvent.previousFeeBps !== null + ? Number(typedEvent.previousFeeBps) + : null; + + // Resolve key to creator profile + const profile = await prisma.creatorProfile.findFirst({ + where: { + OR: [{ id: typedEvent.keyId }, { handle: typedEvent.keyId }], + }, + select: { id: true }, + }); + + const resolvedKeyId = profile ? profile.id : typedEvent.keyId; + + const occurredAt = typedEvent.timestamp + ? new Date(typedEvent.timestamp) + : new Date(); + + // Persist to key_fee_history idempotently + await prisma.keyFeeHistory.upsert({ + where: { + txHash_eventIndex: { + txHash: typedEvent.txHash, + eventIndex: typedEvent.eventIndex, + }, + }, + create: { + keyId: resolvedKeyId, + feeBps, + previousFeeBps, + volumeBracket: typedEvent.volumeBracket ?? null, + rolling24hVolume: typedEvent.rolling24hVolume ?? null, + ledger: Number(typedEvent.ledger), + txHash: typedEvent.txHash, + eventIndex: Number(typedEvent.eventIndex), + timestamp: occurredAt, + }, + update: { + feeBps, + previousFeeBps, + volumeBracket: typedEvent.volumeBracket ?? null, + rolling24hVolume: typedEvent.rolling24hVolume ?? null, + timestamp: occurredAt, + }, + }); + + // Invalidate 60s swap fee cache for the affected key + await invalidateKeyFeeCache(resolvedKeyId); + + logger.info( + { + type: 'fee_adjusted_indexed', + keyId: resolvedKeyId, + feeBps, + txHash: typedEvent.txHash, + eventIndex: typedEvent.eventIndex, + ledger: typedEvent.ledger, + }, + 'FeeAdjusted event indexed and cache invalidated' + ); + }); +} diff --git a/src/modules/keys/key-swap-fee-contract.ts b/src/modules/keys/key-swap-fee-contract.ts new file mode 100644 index 00000000..d97c2abc --- /dev/null +++ b/src/modules/keys/key-swap-fee-contract.ts @@ -0,0 +1,82 @@ +// src/modules/keys/key-swap-fee-contract.ts +// Reads current swap fee tier from the dynamic fee contract on-chain. + +import { envConfig } from '../../config'; +import { logger } from '../../utils/logger.utils'; + +export type ContractFeeRpcCaller = (keyId: string) => Promise; + +let overrideRpcCaller: ContractFeeRpcCaller | null = null; + +/** + * Set a mock or custom RPC caller for testing or custom environments. + */ +export function setContractFeeRpcCaller( + caller: ContractFeeRpcCaller | null +): void { + overrideRpcCaller = caller; +} + +/** + * Reads get_current_fee_bps for a key from the dynamic fee contract. + * + * Returns null if the contract is unreachable, unconfigured, or call fails, + * enabling the caller to safely fall back to the volume-tier calculation. + */ +export async function fetchCurrentFeeBpsFromContract( + keyId: string +): Promise { + if (overrideRpcCaller) { + return overrideRpcCaller(keyId); + } + + const rpcUrl = envConfig.STELLAR_SOROBAN_RPC_URL; + const contractId = envConfig.DYNAMIC_FEE_CONTRACT_ID; + + if (!rpcUrl || !contractId) { + return null; + } + + try { + // In production Soroban RPC environments, simulate transaction for get_current_fee_bps + const response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'get-current-fee-bps', + method: 'getLedgerEntries', + params: { + keys: [contractId], + }, + }), + }); + + if (!response.ok) { + return null; + } + + const envelope = (await response.json()) as any; + if (envelope?.error) { + return null; + } + + // Check if entry or retval exists and contains a numeric fee + const val = + envelope?.result?.entries?.[0]?.val ?? + envelope?.result?.results?.[0]?.retval; + if (typeof val === 'number') { + return val; + } + return null; + } catch (error) { + logger.debug( + { + keyId, + error: error instanceof Error ? error.message : String(error), + }, + 'Dynamic fee contract read failed; using volume tier fallback' + ); + return null; + } +} diff --git a/src/modules/keys/key-swap-fee.routes.test.ts b/src/modules/keys/key-swap-fee.routes.test.ts new file mode 100644 index 00000000..5d9faaa0 --- /dev/null +++ b/src/modules/keys/key-swap-fee.routes.test.ts @@ -0,0 +1,140 @@ +// src/modules/keys/key-swap-fee.routes.test.ts + +import { KeyNotFoundError } from './key-fees.service'; + +jest.mock('./key-swap-fee.service', () => ({ + getSwapFeeTier: jest.fn(), + getKeyFeeHistory: jest.fn(), + KeyNotFoundError: jest.requireActual('./key-fees.service').KeyNotFoundError, +})); + +import express from 'express'; +import request from 'supertest'; +import keysRouter from './keys.routes'; +import { getSwapFeeTier, getKeyFeeHistory } from './key-swap-fee.service'; + +const mockGetSwapFeeTier = getSwapFeeTier as jest.Mock; +const mockGetKeyFeeHistory = getKeyFeeHistory as jest.Mock; + +const app = express(); +app.use(express.json()); +app.use('/api/v1/keys', keysRouter); +app.use('/keys', keysRouter); + +describe('Key Swap Fee Routes', () => { + const KEY_ID = 'key-uuid-1'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('GET /keys/:keyId/fee and /api/v1/keys/:keyId/fee', () => { + it('returns correct fee bps, volume bracket, and 24h volume', async () => { + const mockData = { + current_fee_bps: 400, + volume_bracket: 'TIER_2', + rolling_24h_volume: '1500000000', + }; + mockGetSwapFeeTier.mockResolvedValue(mockData); + + // Test /api/v1/keys/:keyId/fee + const res1 = await request(app).get(`/api/v1/keys/${KEY_ID}/fee`); + expect(res1.status).toBe(200); + expect(res1.body.success).toBe(true); + expect(res1.body.data).toEqual(mockData); + + // Test /keys/:keyId/fee + const res2 = await request(app).get(`/keys/${KEY_ID}/fee`); + expect(res2.status).toBe(200); + expect(res2.body.success).toBe(true); + expect(res2.body.data).toEqual(mockData); + + expect(mockGetSwapFeeTier).toHaveBeenCalledWith(KEY_ID); + }); + + it('returns 404 for unknown key IDs', async () => { + mockGetSwapFeeTier.mockRejectedValue( + new KeyNotFoundError('unknown-key') + ); + + const res = await request(app).get('/api/v1/keys/unknown-key/fee'); + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('NOT_FOUND'); + }); + }); + + describe('GET /keys/:keyId/fee/history and /api/v1/keys/:keyId/fee/history', () => { + it('returns fee tier change history with timestamps', async () => { + const mockHistory = [ + { + id: 'hist-1', + key_id: KEY_ID, + keyId: KEY_ID, + fee_bps: 400, + feeBps: 400, + current_fee_bps: 400, + previous_fee_bps: 500, + previousFeeBps: 500, + volume_bracket: 'TIER_2', + volumeBracket: 'TIER_2', + rolling_24h_volume: '1500000000', + rolling24hVolume: '1500000000', + ledger: 100, + tx_hash: '0x123', + txHash: '0x123', + event_index: 0, + eventIndex: 0, + timestamp: '2026-10-17T12:00:00.000Z', + created_at: '2026-10-17T12:00:00.000Z', + }, + ]; + mockGetKeyFeeHistory.mockResolvedValue(mockHistory); + + // Test /api/v1/keys/:keyId/fee/history + const res1 = await request(app).get( + `/api/v1/keys/${KEY_ID}/fee/history` + ); + expect(res1.status).toBe(200); + expect(res1.body.success).toBe(true); + expect(res1.body.data).toEqual(mockHistory); + + // Test /keys/:keyId/fee/history + const res2 = await request(app).get(`/keys/${KEY_ID}/fee/history`); + expect(res2.status).toBe(200); + expect(res2.body.success).toBe(true); + expect(res2.body.data).toEqual(mockHistory); + + expect(mockGetKeyFeeHistory).toHaveBeenCalledWith(KEY_ID, { + limit: 50, + offset: 0, + }); + }); + + it('supports custom limit and offset query parameters', async () => { + mockGetKeyFeeHistory.mockResolvedValue([]); + + await request(app).get( + `/api/v1/keys/${KEY_ID}/fee/history?limit=10&offset=5` + ); + + expect(mockGetKeyFeeHistory).toHaveBeenCalledWith(KEY_ID, { + limit: 10, + offset: 5, + }); + }); + + it('returns 404 for unknown key IDs in fee history', async () => { + mockGetKeyFeeHistory.mockRejectedValue( + new KeyNotFoundError('unknown-key') + ); + + const res = await request(app).get( + '/api/v1/keys/unknown-key/fee/history' + ); + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('NOT_FOUND'); + }); + }); +}); diff --git a/src/modules/keys/key-swap-fee.service.test.ts b/src/modules/keys/key-swap-fee.service.test.ts new file mode 100644 index 00000000..096abfb9 --- /dev/null +++ b/src/modules/keys/key-swap-fee.service.test.ts @@ -0,0 +1,217 @@ +// src/modules/keys/key-swap-fee.service.test.ts +const cacheStore = new Map(); + +jest.mock('../../utils/redis.utils', () => ({ + cacheGetJson: jest.fn(async (key: string) => + cacheStore.has(key) ? cacheStore.get(key) : null + ), + cacheSetJson: jest.fn(async (key: string, value: unknown) => { + cacheStore.set(key, value); + }), + cacheInvalidate: jest.fn(async (...keys: string[]) => { + for (const k of keys) { + cacheStore.delete(k); + } + }), +})); + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + creatorProfile: { + findFirst: jest.fn(), + }, + keyFeeHistory: { + findMany: jest.fn(), + }, + }, +})); + +jest.mock('../../utils/trading-volume.utils', () => ({ + compute24hVolume: jest.fn(), +})); + +jest.mock('../protocol/fee-tier.service', () => ({ + loadFeeTierConfig: jest.fn(), +})); + +jest.mock('./key-swap-fee-contract', () => ({ + fetchCurrentFeeBpsFromContract: jest.fn(), +})); + +import { prisma } from '../../utils/prisma.utils'; +import { cacheSetJson } from '../../utils/redis.utils'; +import { compute24hVolume } from '../../utils/trading-volume.utils'; +import { loadFeeTierConfig } from '../protocol/fee-tier.service'; +import { fetchCurrentFeeBpsFromContract } from './key-swap-fee-contract'; +import { + getSwapFeeTier, + getKeyFeeHistory, + invalidateKeyFeeCache, + KeyNotFoundError, + SWAP_FEE_CACHE_TTL_SECONDS, + swapFeeCacheKey, +} from './key-swap-fee.service'; + +const mockPrisma = prisma as unknown as { + creatorProfile: { findFirst: jest.Mock }; + keyFeeHistory: { findMany: jest.Mock }; +}; +const mockCacheSetJson = cacheSetJson as jest.Mock; +const mockCompute24hVolume = compute24hVolume as jest.Mock; +const mockLoadFeeTierConfig = loadFeeTierConfig as jest.Mock; +const mockFetchContractFee = fetchCurrentFeeBpsFromContract as jest.Mock; + +describe('key-swap-fee.service', () => { + const KEY_ID = 'key-uuid-1'; + const DEFAULT_TIERS = [ + { volumeThreshold: 0, feeBps: 500, label: 'TIER_1' }, + { volumeThreshold: 1_000_000_000, feeBps: 400, label: 'TIER_2' }, + { volumeThreshold: 5_000_000_000, feeBps: 300, label: 'TIER_3' }, + ]; + + beforeEach(() => { + cacheStore.clear(); + jest.clearAllMocks(); + + mockPrisma.creatorProfile.findFirst.mockResolvedValue({ + id: KEY_ID, + handle: 'alice', + }); + + mockCompute24hVolume.mockResolvedValue(2_000_000_000n); + mockLoadFeeTierConfig.mockResolvedValue(DEFAULT_TIERS); + mockFetchContractFee.mockResolvedValue(400); + }); + + describe('getSwapFeeTier', () => { + it('returns correct bps, bracket, and volume values', async () => { + const result = await getSwapFeeTier(KEY_ID); + + expect(result).toEqual({ + current_fee_bps: 400, + volume_bracket: 'TIER_2', + rolling_24h_volume: '2000000000', + }); + expect(mockPrisma.creatorProfile.findFirst).toHaveBeenCalledWith({ + where: { OR: [{ id: KEY_ID }, { handle: KEY_ID }] }, + select: { id: true, handle: true }, + }); + expect(mockCompute24hVolume).toHaveBeenCalledWith(KEY_ID); + expect(mockFetchContractFee).toHaveBeenCalledWith(KEY_ID); + expect(mockCacheSetJson).toHaveBeenCalledWith( + swapFeeCacheKey(KEY_ID), + { + current_fee_bps: 400, + volume_bracket: 'TIER_2', + rolling_24h_volume: '2000000000', + }, + SWAP_FEE_CACHE_TTL_SECONDS + ); + }); + + it('falls back to volume tier fee when contract read returns null', async () => { + mockFetchContractFee.mockResolvedValue(null); + + const result = await getSwapFeeTier(KEY_ID); + + expect(result).toEqual({ + current_fee_bps: 400, + volume_bracket: 'TIER_2', + rolling_24h_volume: '2000000000', + }); + }); + + it('uses 60s cache to reduce RPC calls under normal traffic', async () => { + // First call (cache miss) -> calls contract + const res1 = await getSwapFeeTier(KEY_ID); + expect(res1.current_fee_bps).toBe(400); + expect(mockFetchContractFee).toHaveBeenCalledTimes(1); + + // Second call within 60s (cache hit) -> does NOT call contract RPC + const res2 = await getSwapFeeTier(KEY_ID); + expect(res2).toEqual(res1); + expect(mockFetchContractFee).toHaveBeenCalledTimes(1); + }); + + it('refetches from contract after cache invalidation', async () => { + await getSwapFeeTier(KEY_ID); + expect(mockFetchContractFee).toHaveBeenCalledTimes(1); + + // Invalidate cache + await invalidateKeyFeeCache(KEY_ID); + + // Call again -> triggers contract read + await getSwapFeeTier(KEY_ID); + expect(mockFetchContractFee).toHaveBeenCalledTimes(2); + }); + + it('throws KeyNotFoundError for unknown key IDs', async () => { + mockPrisma.creatorProfile.findFirst.mockResolvedValue(null); + + await expect(getSwapFeeTier('unknown-key')).rejects.toThrow( + KeyNotFoundError + ); + expect(mockFetchContractFee).not.toHaveBeenCalled(); + }); + }); + + describe('getKeyFeeHistory', () => { + it('returns fee tier change records with timestamps', async () => { + const mockDate = new Date('2026-10-17T12:00:00Z'); + mockPrisma.keyFeeHistory.findMany.mockResolvedValue([ + { + id: 'h-1', + keyId: KEY_ID, + feeBps: 400, + previousFeeBps: 500, + volumeBracket: 'TIER_2', + rolling24hVolume: '2000000000', + ledger: 100, + txHash: '0xabc', + eventIndex: 0, + timestamp: mockDate, + createdAt: mockDate, + }, + ]); + + const history = await getKeyFeeHistory(KEY_ID); + + expect(history).toHaveLength(1); + expect(history[0]).toEqual({ + id: 'h-1', + key_id: KEY_ID, + keyId: KEY_ID, + fee_bps: 400, + feeBps: 400, + current_fee_bps: 400, + previous_fee_bps: 500, + previousFeeBps: 500, + volume_bracket: 'TIER_2', + volumeBracket: 'TIER_2', + rolling_24h_volume: '2000000000', + rolling24hVolume: '2000000000', + ledger: 100, + tx_hash: '0xabc', + txHash: '0xabc', + event_index: 0, + eventIndex: 0, + timestamp: mockDate.toISOString(), + created_at: mockDate.toISOString(), + }); + expect(mockPrisma.keyFeeHistory.findMany).toHaveBeenCalledWith({ + where: { keyId: KEY_ID }, + orderBy: { timestamp: 'desc' }, + take: 50, + skip: 0, + }); + }); + + it('throws KeyNotFoundError for unknown key IDs', async () => { + mockPrisma.creatorProfile.findFirst.mockResolvedValue(null); + + await expect(getKeyFeeHistory('unknown-key')).rejects.toThrow( + KeyNotFoundError + ); + }); + }); +}); diff --git a/src/modules/keys/key-swap-fee.service.ts b/src/modules/keys/key-swap-fee.service.ts new file mode 100644 index 00000000..e9d45d6a --- /dev/null +++ b/src/modules/keys/key-swap-fee.service.ts @@ -0,0 +1,171 @@ +// src/modules/keys/key-swap-fee.service.ts +// Service for fetching dynamic swap fee tier, volume bracket, rolling 24h volume, +// and FeeAdjusted change history with 60s TTL caching. + +import { prisma } from '../../utils/prisma.utils'; +import { + cacheGetJson, + cacheSetJson, + cacheInvalidate, +} from '../../utils/redis.utils'; +import { compute24hVolume } from '../../utils/trading-volume.utils'; +import { loadFeeTierConfig } from '../protocol/fee-tier.service'; +import { fetchCurrentFeeBpsFromContract } from './key-swap-fee-contract'; +import { KeyNotFoundError } from './key-fees.service'; + +export { KeyNotFoundError }; + +export interface SwapFeeTierResult { + current_fee_bps: number; + volume_bracket: string; + rolling_24h_volume: string; +} + +export interface KeyFeeHistoryRecord { + id: string; + key_id: string; + keyId: string; + fee_bps: number; + feeBps: number; + current_fee_bps: number; + previous_fee_bps: number | null; + previousFeeBps: number | null; + volume_bracket: string | null; + volumeBracket: string | null; + rolling_24h_volume: string | null; + rolling24hVolume: string | null; + ledger: number; + tx_hash: string; + txHash: string; + event_index: number; + eventIndex: number; + timestamp: string; + created_at: string; +} + +export const SWAP_FEE_CACHE_TTL_SECONDS = 60; + +export function swapFeeCacheKey(keyId: string): string { + return `key:swap-fee:${keyId}`; +} + +/** + * Returns the current swap fee tier for a given creator key. + * + * Sourced from the dynamic fee contract `get_current_fee_bps` call, + * with 60s cache TTL and invalidation on FeeAdjusted events. + */ +export async function getSwapFeeTier( + keyId: string +): Promise { + const profile = await prisma.creatorProfile.findFirst({ + where: { OR: [{ id: keyId }, { handle: keyId }] }, + select: { id: true, handle: true }, + }); + + if (!profile) { + throw new KeyNotFoundError(keyId); + } + + const cacheKey = swapFeeCacheKey(profile.id); + const cached = await cacheGetJson(cacheKey); + if (cached && typeof cached.current_fee_bps === 'number') { + return cached; + } + + // 1. Calculate 24h rolling volume + const volume24h = await compute24hVolume(profile.id); + + // 2. Resolve volume bracket from configured fee tiers + const tiers = await loadFeeTierConfig(); + const sortedTiers = [...tiers].sort( + (a, b) => b.volumeThreshold - a.volumeThreshold + ); + + let activeTier = tiers[0]; + for (const tier of sortedTiers) { + if (volume24h >= BigInt(tier.volumeThreshold)) { + activeTier = tier; + break; + } + } + + // 3. Read contract get_current_fee_bps, falling back to volume tier fee + const contractFeeBps = await fetchCurrentFeeBpsFromContract(profile.id); + const currentFeeBps = + contractFeeBps !== null ? contractFeeBps : activeTier.feeBps; + + const result: SwapFeeTierResult = { + current_fee_bps: currentFeeBps, + volume_bracket: activeTier.label, + rolling_24h_volume: volume24h.toString(), + }; + + // 4. Cache with 60s TTL + await cacheSetJson(cacheKey, result, SWAP_FEE_CACHE_TTL_SECONDS); + + return result; +} + +/** + * Invalidate the 60s swap fee cache for a key (called on FeeAdjusted event). + */ +export async function invalidateKeyFeeCache(keyId: string): Promise { + const keysToInvalidate = [swapFeeCacheKey(keyId), `key:swap-fee:${keyId}`]; + await cacheInvalidate(...keysToInvalidate); +} + +/** + * Returns fee tier change history for a given creator key with timestamps. + */ +export async function getKeyFeeHistory( + keyId: string, + options?: { limit?: number; offset?: number } +): Promise { + const profile = await prisma.creatorProfile.findFirst({ + where: { OR: [{ id: keyId }, { handle: keyId }] }, + select: { id: true }, + }); + + if (!profile) { + throw new KeyNotFoundError(keyId); + } + + const limit = options?.limit && options.limit > 0 ? options.limit : 50; + const offset = options?.offset && options.offset >= 0 ? options.offset : 0; + + const records = await prisma.keyFeeHistory.findMany({ + where: { keyId: profile.id }, + orderBy: { timestamp: 'desc' }, + take: limit, + skip: offset, + }); + + return records.map(r => ({ + id: r.id, + key_id: r.keyId, + keyId: r.keyId, + fee_bps: r.feeBps, + feeBps: r.feeBps, + current_fee_bps: r.feeBps, + previous_fee_bps: r.previousFeeBps, + previousFeeBps: r.previousFeeBps, + volume_bracket: r.volumeBracket, + volumeBracket: r.volumeBracket, + rolling_24h_volume: r.rolling24hVolume, + rolling24hVolume: r.rolling24hVolume, + ledger: r.ledger, + tx_hash: r.txHash, + txHash: r.txHash, + event_index: r.eventIndex, + eventIndex: r.eventIndex, + timestamp: + r.timestamp instanceof Date + ? r.timestamp.toISOString() + : String(r.timestamp), + created_at: + r.createdAt instanceof Date + ? r.createdAt.toISOString() + : String(r.createdAt), + })); +} diff --git a/src/modules/keys/keys.routes.ts b/src/modules/keys/keys.routes.ts index 524e9ee5..1295686f 100644 --- a/src/modules/keys/keys.routes.ts +++ b/src/modules/keys/keys.routes.ts @@ -908,46 +908,93 @@ router.get('/:keyId/fees', async (req, res, next) => { } }); +import { getSwapFeeTier, getKeyFeeHistory } from './key-swap-fee.service'; + const dynamicFeeQuerySchema = z.object({ amount: z.coerce.number().int().nonnegative(), direction: z.enum(['buy', 'sell']), wallet: StellarAddressSchema.optional(), }); +const feeHistoryQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).default(50), + offset: z.coerce.number().int().nonnegative().default(0), +}); + /** - * GET /api/v1/keys/:keyId/fee - * Computes the effective dynamic fee rate for a given trade, factoring in base fee, - * volume-tier discount, protocol fee, and creator royalty (#962). + * GET /api/v1/keys/:keyId/fee/history + * Returns fee tier change history with timestamps from FeeAdjusted events. */ -router.get('/:keyId/fee', async (req, res, next) => { - const parsed = dynamicFeeQuerySchema.safeParse(req.query); +router.get('/:keyId/fee/history', async (req, res, next) => { + const parsed = feeHistoryQuerySchema.safeParse(req.query); if (!parsed.success) { sendValidationError( res, - 'Invalid dynamic fee query', + 'Invalid fee history query parameters', zodIssuesToDetails(parsed.error.issues) ); return; } try { - // We dynamically load the service here to avoid massive circular imports if any - const { getDynamicFeeRate } = await import('./key-dynamic-fee.service'); + const history = await getKeyFeeHistory(req.params.keyId, parsed.data); + sendSuccess(res, history); + } catch (error) { + if (error instanceof KeyNotFoundError) { + sendNotFound(res, 'Key'); + return; + } + next(error); + } +}); - // If the request is authenticated, we use the user's wallet for the volume discount - // In a real implementation we would extract the wallet from the JWT middleware (req.user), - // but this endpoint is public. We optionally accept a wallet address to preview discount. - const wallet = parsed.data.wallet; +/** + * GET /api/v1/keys/:keyId/fee + * Returns the current swap fee tier for a given creator key by reading the dynamic fee contract, + * including current volume bracket, fee in basis points, and rolling 24h volume (#1055). + * Cached with 60s TTL. + */ +router.get('/:keyId/fee', async (req, res, next) => { + // If trade calculation query params are provided, handle dynamic fee rate computation + if (req.query.amount !== undefined && req.query.direction !== undefined) { + const parsed = dynamicFeeQuerySchema.safeParse(req.query); + if (!parsed.success) { + sendValidationError( + res, + 'Invalid dynamic fee query', + zodIssuesToDetails(parsed.error.issues) + ); + return; + } - sendSuccess( - res, - await getDynamicFeeRate( - req.params.keyId, - parsed.data.amount, - parsed.data.direction, - wallet - ) - ); + try { + const { getDynamicFeeRate } = + await import('./key-dynamic-fee.service'); + const wallet = parsed.data.wallet; + + sendSuccess( + res, + await getDynamicFeeRate( + req.params.keyId, + parsed.data.amount, + parsed.data.direction, + wallet + ) + ); + return; + } catch (error) { + if (error instanceof KeyNotFoundError) { + sendNotFound(res, 'Key'); + return; + } + next(error); + return; + } + } + + try { + const feeTier = await getSwapFeeTier(req.params.keyId); + sendSuccess(res, feeTier); } catch (error) { if (error instanceof KeyNotFoundError) { sendNotFound(res, 'Key');