diff --git a/docs/api-inventory.md b/docs/api-inventory.md index 61856c91..f2b87a96 100644 --- a/docs/api-inventory.md +++ b/docs/api-inventory.md @@ -94,10 +94,11 @@ events. - Referral tier reference: [`docs/referral-tiers.md`](./referral-tiers.md). -| Method | Path | Description | -| :----- | :-------------------------- | :----------------------------------------------------------------------- | -| `GET` | `/referrals/tier` | Current tier, cumulative volume, next threshold and reward rate (bps). | -| `GET` | `/referrals/tier/history` | Cursor-paginated tier upgrade history for a wallet. | +| Method | Path | Description | +| :----- | :------------------------ | :--------------------------------------------------------------------- | +| `GET` | `/referrals/tier` | Current tier, cumulative volume, next threshold and reward rate (bps). | +| `GET` | `/referrals/tier/history` | Cursor-paginated tier upgrade history for a wallet. | + Wallet-scoped referral reward earnings indexed from `ReferralRewardDistributed` events. diff --git a/docs/referral-tiers.md b/docs/referral-tiers.md index 8c926cf9..536c29bd 100644 --- a/docs/referral-tiers.md +++ b/docs/referral-tiers.md @@ -12,11 +12,11 @@ Tiers are defined in `src/modules/referrals/referral-tier.constants.ts`. A wallet's tier is a function of the cumulative volume of referrals it has driven; the reward rate (basis points) is applied to referred volume. -| Tier | Minimum cumulative volume (XLM) | Reward rate | -| :--- | :------------------------------ | :-------------- | -| 1 | 0 | 500 bps (5%) | -| 2 | 1,000 | 750 bps (7.5%) | -| 3 | 10,000 | 1000 bps (10%) | +| Tier | Minimum cumulative volume (XLM) | Reward rate | +| :--- | :------------------------------ | :------------- | +| 1 | 0 | 500 bps (5%) | +| 2 | 1,000 | 750 bps (7.5%) | +| 3 | 10,000 | 1000 bps (10%) | Tier 1 is the entry tier, so every wallet always has a tier. Once a wallet is at the top tier, `nextThreshold` and `distanceToNextThreshold` are `null`. @@ -29,28 +29,28 @@ Tier state is stored in two tables (`prisma/schema/referral.prisma`): Current tier per wallet — one row per referrer. -| Field | Type | Notes | -| :----------------- | :-------------- | :--------------------------------------- | -| `walletAddress` | `String` | Unique referrer wallet. | -| `tier` | `Int` | Highest tier reached (1, 2 or 3). | -| `cumulativeVolume` | `Decimal(20,7)` | Cumulative referred volume in XLM. | -| `lastUpgradedAt` | `DateTime?` | Timestamp of the most recent upgrade. | -| `updatedAt` | `DateTime` | Last mutation time. | +| Field | Type | Notes | +| :----------------- | :-------------- | :------------------------------------ | +| `walletAddress` | `String` | Unique referrer wallet. | +| `tier` | `Int` | Highest tier reached (1, 2 or 3). | +| `cumulativeVolume` | `Decimal(20,7)` | Cumulative referred volume in XLM. | +| `lastUpgradedAt` | `DateTime?` | Timestamp of the most recent upgrade. | +| `updatedAt` | `DateTime` | Last mutation time. | ### `referral_tier_events` (`ReferralTierEvent`) Append-only upgrade log — one row per `ReferralTierUpgraded` event. -| Field | Type | Notes | -| :----------------- | :-------------- | :--------------------------------------- | -| `walletAddress` | `String` | Referrer that upgraded. | -| `previousTier` | `Int` | Tier before the upgrade. | -| `newTier` | `Int` | Tier after the upgrade. | -| `cumulativeVolume` | `Decimal(20,7)` | Volume at the time of the upgrade. | -| `txHash` | `String` | Transaction hash. | -| `eventIndex` | `Int` | Event index within the transaction. | -| `ledger` | `Int` | Ledger the event was observed in. | -| `occurredAt` | `DateTime` | On-chain event timestamp. | +| Field | Type | Notes | +| :----------------- | :-------------- | :---------------------------------- | +| `walletAddress` | `String` | Referrer that upgraded. | +| `previousTier` | `Int` | Tier before the upgrade. | +| `newTier` | `Int` | Tier after the upgrade. | +| `cumulativeVolume` | `Decimal(20,7)` | Volume at the time of the upgrade. | +| `txHash` | `String` | Transaction hash. | +| `eventIndex` | `Int` | Event index within the transaction. | +| `ledger` | `Int` | Ledger the event was observed in. | +| `occurredAt` | `DateTime` | On-chain event timestamp. | `(txHash, eventIndex)` is unique, so replaying the same chain event never records a duplicate upgrade. @@ -80,10 +80,10 @@ Event shape: interface ReferralTierUpgradedEvent { eventType: 'REFERRAL_TIER_UPGRADED'; walletAddress: string; - previousTier: number; // 0 for a wallet's first tier - newTier: number; // 1 | 2 | 3 + previousTier: number; // 0 for a wallet's first tier + newTier: number; // 1 | 2 | 3 cumulativeVolume: string; // XLM, string for precision - upgradedAt: string; // ISO timestamp + upgradedAt: string; // ISO timestamp ledger: number; txHash: string; eventIndex: number; @@ -101,9 +101,9 @@ returns `401`. Current tier and the metadata needed to render progress towards the next tier. Cached in Redis for 5 minutes per wallet (invalidated when an upgrade lands). -| Query param | Type | Notes | -| :---------- | :----- | :---------------------------------------- | -| `wallet` | string | Required. Referrer wallet address. | +| Query param | Type | Notes | +| :---------- | :----- | :--------------------------------- | +| `wallet` | string | Required. Referrer wallet address. | ```json { @@ -124,11 +124,11 @@ Cached in Redis for 5 minutes per wallet (invalidated when an upgrade lands). Cursor-paginated upgrade log, newest first. -| Query param | Type | Default | Notes | -| :---------- | :------ | :------ | :------------------------------------- | -| `wallet` | string | – | Required. Referrer wallet address. | -| `limit` | integer | `20` | 1–100. | -| `cursor` | string | – | Opaque cursor from the previous page. | +| Query param | Type | Default | Notes | +| :---------- | :------ | :------ | :------------------------------------ | +| `wallet` | string | – | Required. Referrer wallet address. | +| `limit` | integer | `20` | 1–100. | +| `cursor` | string | – | Opaque cursor from the previous page. | ```json { 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 8cd37b8b..778c80c0 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,11 +5,11 @@ 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 tradesRouter from './modules/trades/trades.routes'; import tradersRouter from './modules/traders/traders.routes'; import nonceRouter from './modules/nonce/nonce.routes'; import swapsRouter from './modules/swaps/swaps.routes'; -import keysRouter from './modules/keys/keys.routes'; import milestonesRouter from './modules/keys/milestones.routes'; import internalRouter from './modules/admin/sequencer.routes'; import eventsRouter from './modules/events/events.routes'; diff --git a/src/config.schema.ts b/src/config.schema.ts index 80b316b8..e35db254 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, + // Nonce registry contract (#1039): deployed contract id holding the // on-chain single-use nonce registry, and short TTL for cached nonces. NONCE_REGISTRY_CONTRACT_ID: optionalNonEmptyString, 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 56b4fae3..af3cfefa 100644 --- a/src/modules/keys/keys.routes.ts +++ b/src/modules/keys/keys.routes.ts @@ -1150,46 +1150,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');