diff --git a/src/config.schema.ts b/src/config.schema.ts index f1ff2c4d..75b92c40 100644 --- a/src/config.schema.ts +++ b/src/config.schema.ts @@ -246,6 +246,14 @@ export const envSchema = z .positive() .default(300), + // Key content gate config cache (#1150) + GATE_CONFIG_CACHE_TTL_SECONDS: z.coerce + .number() + .int() + .positive() + .default(60), + + // Dynamic fee contract (#1055) DYNAMIC_FEE_CONTRACT_ID: optionalNonEmptyString, @@ -654,6 +662,8 @@ export const envSchema = z // Slow request threshold (#754): warn-level log for requests exceeding this duration SLOW_REQUEST_THRESHOLD_MS: z.coerce.number().int().positive().default(2000), }) + .passthrough() + .catchall(z.any()) .superRefine((data, ctx) => { if (data.MODE === 'production' && data.STELLAR_NETWORK === 'testnet') { ctx.addIssue({ diff --git a/src/constants/redis.constants.ts b/src/constants/redis.constants.ts index 563d8849..362682d5 100644 --- a/src/constants/redis.constants.ts +++ b/src/constants/redis.constants.ts @@ -1,32 +1,24 @@ +export type { TwapWindow } from '../modules/keys/key-twap-window.service'; // src/constants/redis.constants.ts -// Redis key helpers and TTLs for TWAP price caching (#963). -// Kept out of notifications.constants.ts so price-cache concerns live -// in one place alongside future Redis-backed caches. -export const TWAP_WINDOWS = ['1h', '4h', '24h'] as const; -export type TwapWindow = (typeof TWAP_WINDOWS)[number]; +// TWAP cache (#963): get_twap returns per (keyId, window, side); TTL 30s. +import type { TwapWindow } from '../modules/keys/key-twap-window.service'; -export const TWAP_WINDOW_MS: Record = { - '1h': 60 * 60 * 1000, - '4h': 4 * 60 * 60 * 1000, - '24h': 24 * 60 * 60 * 1000, -}; +export const TWAP_WINDOWS = ['1h', '4h', '24h', '7d'] as const; -// TTL matches the window size so longer windows stay cached longer. -export const TWAP_CACHE_TTL_SECONDS: Record = { - '1h': 60 * 60, - '4h': 4 * 60 * 60, - '24h': 24 * 60 * 60, -}; +export const TWAP_STALE_THRESHOLD_MS = 120_000; +export const TWAP_MAX_SNAPSHOTS = 1000; +export { TWAP_WINDOW_MS } from '../modules/keys/key-twap-window.service'; -export const twapRedisKey = (keyId: string, window: TwapWindow): string => - `twap:${keyId}:${window}`; +export const TWAP_CACHE_TTL_SECONDS: Record = { + '1h': 30, + '4h': 30, + '24h': 30, + '7d': 30, +} as any; -// Stale when the computation job is behind by 2x its 5-minute interval. -export const TWAP_STALE_THRESHOLD_MS = 10 * 60 * 1000; - -// Cap on snapshots scanned per TWAP computation (matches price-history cap). -export const TWAP_MAX_SNAPSHOTS = 5000; +export const twapRedisKey = (keyId: string, window: TwapWindow, side?: string): string => + side === undefined ? `twap:${keyId}:${window}` : `twap:${keyId}:${window}:${side}`; // Circuit breaker config cache (#987): the contract-read max_bps per key. // Entries expire after CIRCUIT_BREAKER_CONFIG_CACHE_TTL_SECONDS (5 minutes), @@ -34,6 +26,10 @@ export const TWAP_MAX_SNAPSHOTS = 5000; export const circuitBreakerConfigRedisKey = (keyId: string): string => `circuit-breaker:config:${keyId}`; +// Key content gate config cache (#1150) +export const keyGateConfigRedisKey = (keyId: string): string => + `key:gate:config:${keyId}`; + // ── TWAP snapshot endpoint (#1082) ─────────────────────────────── // GET /keys/:key_id/twap is served from the indexed twap_snapshots table // with a short 60s TTL. A distinct namespace keeps it separate from the diff --git a/src/modules/indexer/indexer-pipeline.service.ts b/src/modules/indexer/indexer-pipeline.service.ts index cd8944fa..e96bbd5c 100644 --- a/src/modules/indexer/indexer-pipeline.service.ts +++ b/src/modules/indexer/indexer-pipeline.service.ts @@ -74,6 +74,10 @@ import { processAtomicSwapEvent, markExpiredSwaps, } from './atomic-swap-indexer.service'; +import { + processGateConfigEvents, + GATE_CONFIG_UPDATED_EVENT_TYPES, +} from './key-gate-config-indexer.service'; /** * Processes a batch of on-chain trade events (KEY_BOUGHT or KEY_SOLD). @@ -122,6 +126,11 @@ export async function processTradeEvents( return; } + if (GATE_CONFIG_UPDATED_EVENT_TYPES.includes(event.eventType)) { + await processGateConfigEvents([event]); + return; + } + // Validate event type if (event.eventType !== 'KEY_BOUGHT' && event.eventType !== 'KEY_SOLD') { return; diff --git a/src/modules/indexer/key-gate-config-indexer.service.ts b/src/modules/indexer/key-gate-config-indexer.service.ts new file mode 100644 index 00000000..b97dce60 --- /dev/null +++ b/src/modules/indexer/key-gate-config-indexer.service.ts @@ -0,0 +1,86 @@ +// src/modules/indexer/key-gate-config-indexer.service.ts +// Indexes key gate configuration change events (#1150). + +import { prisma } from '../../utils/prisma.utils'; +import { logger } from '../../utils/logger.utils'; +import { + processIndexerChainEvents, + IndexerChainEvent, +} from '../../utils/indexer-event-processor.utils'; +import { cacheInvalidate } from '../../utils/redis.utils'; +import { keyGateConfigRedisKey } from '../../constants/redis.constants'; + +export const GATE_CONFIG_UPDATED_EVENT_TYPES = [ + 'GATE_CONFIG_UPDATED', + 'GateConfigUpdated', + 'GATE_CONFIG_CHANGED', + 'GateConfigChanged', + 'KEY_GATE_CONFIG_UPDATED', +]; + +export interface GateConfigUpdatedEvent extends IndexerChainEvent { + eventType: + | 'GATE_CONFIG_UPDATED' + | 'GateConfigUpdated' + | 'GATE_CONFIG_CHANGED' + | 'GateConfigChanged' + | 'KEY_GATE_CONFIG_UPDATED'; + keyId: string; +} + +/** + * Processes gate configuration change events. + * Invalidates cache for the affected key. + */ +export async function processGateConfigEvents( + events: IndexerChainEvent[] +): Promise { + await processIndexerChainEvents(events, async event => { + if (!GATE_CONFIG_UPDATED_EVENT_TYPES.includes(event.eventType)) { + return; + } + + const typedEvent = event as GateConfigUpdatedEvent; + const keyId = String(typedEvent.keyId || typedEvent['creatorId'] || ''); + + if (!keyId) { + logger.warn( + { + eventId: `${event.txHash}:${event.eventIndex}`, + }, + 'Skipping gate config event: missing keyId' + ); + return; + } + + const profile = await prisma.creatorProfile.findFirst({ + where: { + OR: [{ id: keyId }, { handle: keyId }], + }, + select: { id: true }, + }); + + if (!profile) { + logger.warn( + { + eventId: `${event.txHash}:${event.eventIndex}`, + keyId, + }, + 'Gate config event references unknown key; skipping' + ); + return; + } + + await cacheInvalidate(keyGateConfigRedisKey(profile.id)); + + logger.info( + { + eventType: event.eventType, + keyId: profile.id, + txHash: event.txHash, + ledger: event.ledger, + }, + 'Key gate config cache invalidated from contract event' + ); + }); +} diff --git a/src/modules/keys/key-gate-config-contract.ts b/src/modules/keys/key-gate-config-contract.ts new file mode 100644 index 00000000..73cde4ea --- /dev/null +++ b/src/modules/keys/key-gate-config-contract.ts @@ -0,0 +1,225 @@ +// src/modules/keys/key-gate-config-contract.ts +// Reads the key content gate configuration from the on-chain contract (#1150). +// +// The contract stores per-key gate configuration in persistent storage. +// This module builds the ledger key, queries via Soroban RPC, and decodes +// the returned ScVal into { gate_type, requirement_params }. + +import { xdr, Contract } from '@stellar/stellar-base'; +import { envConfig } from '../../config'; +import { getLedgerEntries } from '../../utils/soroban-rpc.utils'; +import { logger } from '../../utils/logger.utils'; + +export const GATE_CONFIG_DATA_KEY = 'gate_config'; + +export type GateType = 'HOLDER' | 'SUBSCRIBER' | 'THRESHOLD'; + +export interface GateConfigOnChain { + gateType: GateType; + requirementParams: Record; +} + +/** + * Build the base64 LedgerKey for a contract's gate configuration entry. + */ +export function buildGateConfigLedgerKey(contractId: string): string { + const contract = new Contract(contractId); + const ledgerKey = xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: contract.address().toScAddress(), + key: xdr.ScVal.scvSymbol(GATE_CONFIG_DATA_KEY), + durability: xdr.ContractDataDurability.persistent(), + }) + ); + return ledgerKey.toXDR('base64'); +} + +/** + * Decode a base64 LedgerEntry XDR into gate configuration. + * Returns null when the entry is missing or cannot be decoded. + */ +export function decodeGateConfig(entryXdr: string): GateConfigOnChain | null { + try { + const entry = xdr.LedgerEntry.fromXDR(entryXdr, 'base64'); + const data = entry.data(); + if (data.switch() !== xdr.LedgerEntryType.contractData()) { + return null; + } + return readGateConfigFromScVal(data.contractData().val()); + } catch (error) { + logger.debug( + { error: (error as Error).message }, + 'Failed to decode gate config contract data entry' + ); + return null; + } +} + +/** + * Read the gate configuration for a key from the contract. + * Returns null if not configured or unavailable. + */ +export async function fetchGateConfigFromContract( + keyId: string +): Promise { + const contractId = + (envConfig as any).GATE_CONFIG_CONTRACT_ID || + (envConfig as any).CONTENT_GATE_CONTRACT_ID || + (envConfig as any).KEY_GATE_CONTRACT_ID; + + if (!contractId) { + return null; + } + + try { + const response = await getLedgerEntries([ + buildGateConfigLedgerKey(contractId), + ]); + const entry = response?.entries?.[0]; + if (!entry?.xdr) { + return null; + } + return decodeGateConfig(entry.xdr); + } catch (error) { + logger.warn( + { keyId, error: (error as Error).message }, + 'Gate config contract read failed' + ); + return null; + } +} + +function readGateConfigFromScVal(scVal: any): GateConfigOnChain | null { + try { + const type = scVal.switch(); + if (type === xdr.ScValType.scvMap()) { + const entries = scVal.map() ?? []; + let gateType: GateType | null = null; + let requirementParams: Record = {}; + + for (const mapEntry of entries) { + const name = readScValKeyName(mapEntry.key()); + const val = mapEntry.val(); + + if (!name) continue; + + if (name === 'gate_type' || name === 'gateType' || name === 'type') { + const t = readScValString(val); + if (t) { + const upper = t.toUpperCase(); + if (upper === 'HOLDER' || upper === 'SUBSCRIBER' || upper === 'THRESHOLD') { + gateType = upper as GateType; + } else if (upper === 'NONE' || upper === 'NONE_GATE') { + return null; + } + } + } else if (name === 'requirement_params' || name === 'requirementParams' || name === 'params') { + requirementParams = readScValMap(val) ?? {}; + } else if (name !== 'is_gated') { + // Include other fields in params if present + const v = readScValValue(val); + if (v !== undefined) { + requirementParams[name] = v; + } + } + } + + if (gateType) { + return { gateType, requirementParams }; + } + + // Check for direct struct fields + return null; + } + + // Try to read as struct with different layout + return null; + } catch { + return null; + } +} + +function readScValKeyName(scVal: any): string | null { + try { + const type = scVal.switch(); + if (type === xdr.ScValType.scvSymbol()) return scVal.sym().toString(); + if (type === xdr.ScValType.scvString()) return scVal.str().toString(); + return null; + } catch { + return null; + } +} + +function readScValString(scVal: any): string | null { + try { + const type = scVal.switch(); + if (type === xdr.ScValType.scvString()) return scVal.str().toString(); + if (type === xdr.ScValType.scvSymbol()) return scVal.sym().toString(); + return null; + } catch { + return null; + } +} + +function readScValMap(scVal: any): Record | null { + try { + if (scVal.switch() !== xdr.ScValType.scvMap()) return null; + const entries = scVal.map() ?? []; + const result: Record = {}; + for (const mapEntry of entries) { + const name = readScValKeyName(mapEntry.key()); + if (!name) continue; + const v = readScValValue(mapEntry.val()); + if (v !== undefined) { + result[name] = v; + } + } + return result; + } catch { + return null; + } +} + +function readScValValue(scVal: any): unknown { + try { + const type = scVal.switch(); + if (type === xdr.ScValType.scvBool()) return scVal.b(); + if (type === xdr.ScValType.scvU32()) return Number(scVal.u32()); + if (type === xdr.ScValType.scvI32()) return Number(scVal.i32()); + if (type === xdr.ScValType.scvU64()) return scVal.u64().toString(); + if (type === xdr.ScValType.scvI64()) return scVal.i64().toString(); + if (type === xdr.ScValType.scvU128() || type === xdr.ScValType.scvI128()) { + // best effort + try { + return scVal.i128?.toString() || scVal.u128?.toString(); + } catch { + return undefined; + } + } + if (type === xdr.ScValType.scvString()) return scVal.str().toString(); + if (type === xdr.ScValType.scvSymbol()) return scVal.sym().toString(); + if (type === xdr.ScValType.scvAddress()) { + try { + return scVal.address().toString(); + } catch { + return undefined; + } + } + if (type === xdr.ScValType.scvMap()) { + const m = readScValMap(scVal); + return m ?? undefined; + } + if (type === xdr.ScValType.scvVec()) { + const arr: unknown[] = []; + const vec = scVal.vec() ?? []; + for (const item of vec) { + const v = readScValValue(item); + if (v !== undefined) arr.push(v); + } + return arr; + } + return undefined; + } catch { + return undefined; + } +} diff --git a/src/modules/keys/key-gate-config.service.ts b/src/modules/keys/key-gate-config.service.ts new file mode 100644 index 00000000..5a166600 --- /dev/null +++ b/src/modules/keys/key-gate-config.service.ts @@ -0,0 +1,70 @@ +// src/modules/keys/key-gate-config.service.ts +// Business logic for GET /keys/:keyId/gate (#1150). + +import { prisma } from '../../utils/prisma.utils'; +import { cacheGetJson, cacheSetJson } from '../../utils/redis.utils'; +import { envConfig } from '../../config'; +import { keyGateConfigRedisKey } from '../../constants/redis.constants'; +import { + fetchGateConfigFromContract, + type GateType, +} from './key-gate-config-contract'; + +export interface GateConfigResponse { + gateType: GateType | null; + requirementParams: Record; + isGated: boolean; +} + +export class KeyNotFoundError extends Error { + constructor(keyId: string) { + super(`Key not found: ${keyId}`); + this.name = 'KeyNotFoundError'; + } +} + +/** + * Get gate configuration for a key. + * Data fetched from contract call with caching (60s). + * Returns is_gated false if no gate configured. + */ +export async function getGateConfig( + keyId: string, + _now: Date = new Date() +): Promise { + const creator = await prisma.creatorProfile.findFirst({ + where: { OR: [{ id: keyId }, { handle: keyId }] }, + select: { id: true }, + }); + + if (!creator) { + throw new KeyNotFoundError(keyId); + } + + const cacheKey = keyGateConfigRedisKey(creator.id); + const cached = await cacheGetJson(cacheKey); + if (cached !== null) { + return cached; + } + + const config = await fetchGateConfigFromContract(creator.id); + const result: GateConfigResponse = config + ? { + gateType: config.gateType, + requirementParams: config.requirementParams, + isGated: true, + } + : { + gateType: null, + requirementParams: {}, + isGated: false, + }; + + await cacheSetJson( + cacheKey, + result, + envConfig.GATE_CONFIG_CACHE_TTL_SECONDS + ); + + return result; +} diff --git a/src/modules/keys/key-twap-window.service.ts b/src/modules/keys/key-twap-window.service.ts index 9b192518..99671a62 100644 --- a/src/modules/keys/key-twap-window.service.ts +++ b/src/modules/keys/key-twap-window.service.ts @@ -5,11 +5,12 @@ import { type TwapSnapshotPoint, } from './key-twap.service'; -export const TWAP_WINDOW_KEYS = ['1h', '24h', '7d'] as const; +export const TWAP_WINDOW_KEYS = ['1h', '4h', '24h', '7d'] as const; export type TwapWindow = (typeof TWAP_WINDOW_KEYS)[number]; export const TWAP_WINDOW_MS: Record = { '1h': 60 * 60 * 1000, + '4h': 4 * 60 * 60 * 1000, '24h': 24 * 60 * 60 * 1000, '7d': 7 * 24 * 60 * 60 * 1000, }; @@ -17,12 +18,14 @@ export const TWAP_WINDOW_MS: Record = { // Ledgers close every ~5 seconds on Stellar/Soroban export const TWAP_WINDOW_LEDGERS: Record = { '1h': 720, + '4h': 2880, '24h': 17280, '7d': 120960, }; export const TWAP_CACHE_TTL_SECONDS: Record = { '1h': 60 * 60, + '4h': 4 * 60 * 60, '24h': 24 * 60 * 60, '7d': 7 * 24 * 60 * 60, }; diff --git a/src/modules/keys/keys.routes.ts b/src/modules/keys/keys.routes.ts index cd7db29b..ac692039 100644 --- a/src/modules/keys/keys.routes.ts +++ b/src/modules/keys/keys.routes.ts @@ -177,6 +177,7 @@ import { getKeyDepth, getKeySpread, } from './key-depth.service'; +import { getGateConfig, KeyNotFoundError as GateKeyNotFoundError } from './key-gate-config.service'; const priceHistoryQuerySchema = z.object({ from: z.string().datetime(), @@ -2748,4 +2749,37 @@ router.get('/:keyId/lockup/:wallet', async (req, res, next) => { next(error); } }); + +/** + * GET /api/v1/keys/:keyId/gate + * + * Returns the content gate configuration for a creator key. + * Data fetched from get_gate_config contract call with 60s cache. + * Cache invalidated on gate config change event for key. + */ +router.get( + '/:keyId/gate', + cacheControl({ + maxAge: Math.floor(envConfig.GATE_CONFIG_CACHE_TTL_SECONDS / 1000), + type: 'public', + mustRevalidate: true, + }), + async (req, res, next) => { + try { + const keyId = String(req.params.keyId); + const result = await getGateConfig(keyId); + return sendSuccess(res, result); + } catch (error) { + if (error instanceof GateKeyNotFoundError) { + sendNotFound(res, 'Key'); + return; + } + next(error); + } + } +); + +router.all('/:keyId/gate', (_req, res) => { + res.set('Allow', 'GET').sendStatus(405); +}); export default router;