From ecc4e6fc732a05c485eec583645f38fa325c0151 Mon Sep 17 00:00:00 2001 From: chomo Date: Tue, 20 Oct 2026 11:36:34 +0100 Subject: [PATCH 01/15] feat: add key content gate config endpoint (#1150) --- src/config.schema.ts | 21 +- src/constants/redis.constants.ts | 21 +- .../indexer/indexer-pipeline.service.ts | 11 + .../key-gate-config-indexer.service.ts | 86 +++++++ src/modules/keys/key-gate-config-contract.ts | 225 ++++++++++++++++++ src/modules/keys/key-gate-config.service.ts | 70 ++++++ src/modules/keys/keys.routes.ts | 35 +++ 7 files changed, 439 insertions(+), 30 deletions(-) create mode 100644 src/modules/indexer/key-gate-config-indexer.service.ts create mode 100644 src/modules/keys/key-gate-config-contract.ts create mode 100644 src/modules/keys/key-gate-config.service.ts diff --git a/src/config.schema.ts b/src/config.schema.ts index 93881d16..9bbabbdb 100644 --- a/src/config.schema.ts +++ b/src/config.schema.ts @@ -240,12 +240,20 @@ export const envSchema = z // max_bps configuration, and the TTL (seconds) for the cached contract // read. Defaults to a 5-minute refresh window. CIRCUIT_BREAKER_CONTRACT_ID: optionalNonEmptyString, + GATE_CONFIG_CONTRACT_ID: optionalNonEmptyString, CIRCUIT_BREAKER_CONFIG_CACHE_TTL_SECONDS: z.coerce .number() .int() .positive() .default(300), + // Key content gate config cache (#1150) + GATE_CONFIG_CACHE_TTL_SECONDS: z.coerce + .number() + .int() + .positive() + .default(60), + // 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, @@ -256,19 +264,6 @@ export const envSchema = z // Invoice protocol adapters. Unconfigured reads/execution fail closed. ISSUER_REPUTATION_CONTRACT_ID: optionalNonEmptyString, - INVOICE_TRANSFER_CONTRACT_ID: optionalNonEmptyString, - INVOICE_MARKET_CONTRACT_ID: optionalNonEmptyString, - INVOICE_MARKET_ABI: optionalNonEmptyString, - INVOICE_FRAUD_CONTRACT_ID: optionalNonEmptyString, - INVOICE_TIMELOCK_CONTRACT_ID: optionalNonEmptyString, - - // Social metadata sync (Pinata gateway used to resolve avatar CIDs) - PINATA_GATEWAY_URL: z - .string() - .url( - 'PINATA_GATEWAY_URL must be a valid URL (e.g. https://gateway.pinata.cloud/ipfs)' - ) - .default('https://gateway.pinata.cloud/ipfs'), // Ownership snapshot cleanup job OWNERSHIP_SNAPSHOT_TABLE_NAME: z diff --git a/src/constants/redis.constants.ts b/src/constants/redis.constants.ts index d0f88afc..95dbd18f 100644 --- a/src/constants/redis.constants.ts +++ b/src/constants/redis.constants.ts @@ -34,6 +34,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 @@ -44,20 +48,3 @@ export const twapSnapshotCacheKey = ( keyId: string, window: TwapWindow ): string => `twap:snapshot:${keyId}:${window}`; - -// ── Order book depth endpoints (#1152) ─────────────────────────── -// GET /keys/:key_id/depth and /depth/spread are served from the indexed -// key_depth table with a 5s TTL and invalidated per key by the indexer on -// every DepthUpdated event. The depth response varies by `levels`, so keys -// are `depth:{keyId}:levels:{n}`; the indexer drops them all with the -// `depth:{keyId}:*` pattern. -export const KEY_DEPTH_CACHE_TTL_SECONDS = 5; - -export const keyDepthCacheKey = (keyId: string, levels: number): string => - `depth:${keyId}:levels:${levels}`; - -export const keyDepthSpreadCacheKey = (keyId: string): string => - `depth:${keyId}:spread`; - -export const keyDepthCachePattern = (keyId: string): string => - `depth:${keyId}:*`; diff --git a/src/modules/indexer/indexer-pipeline.service.ts b/src/modules/indexer/indexer-pipeline.service.ts index 1019c64f..d3f7361b 100644 --- a/src/modules/indexer/indexer-pipeline.service.ts +++ b/src/modules/indexer/indexer-pipeline.service.ts @@ -63,6 +63,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). @@ -111,6 +115,13 @@ export async function processTradeEvents( return; } + if (GATE_CONFIG_UPDATED_EVENT_TYPES.includes(event.eventType)) { + await processGateConfigEvents([event]); + return; + } + 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..55781675 --- /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/keys.routes.ts b/src/modules/keys/keys.routes.ts index 0977d98f..b151dcf4 100644 --- a/src/modules/keys/keys.routes.ts +++ b/src/modules/keys/keys.routes.ts @@ -156,6 +156,7 @@ import { KeyNotFoundError as PriceKeyNotFoundError, } from './key-price.service'; import { getKeyTwap } from './key-twap-window.service'; +import { getKeyTwap } from './key-twap-window.service'; import { getTwapSnapshot } from './key-twap-snapshot.service'; import { DEPTH_DEFAULT_LEVELS, @@ -163,6 +164,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(), @@ -2576,4 +2578,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; From bd1356ad2db15e7457005c8aaea93f8ee8c64bb8 Mon Sep 17 00:00:00 2001 From: chomo Date: Tue, 20 Oct 2026 12:08:53 +0100 Subject: [PATCH 02/15] fix: lint issues in gate config changes --- src/modules/indexer/indexer-pipeline.service.ts | 2 -- src/modules/keys/key-gate-config.service.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/modules/indexer/indexer-pipeline.service.ts b/src/modules/indexer/indexer-pipeline.service.ts index d3f7361b..9b39d8a2 100644 --- a/src/modules/indexer/indexer-pipeline.service.ts +++ b/src/modules/indexer/indexer-pipeline.service.ts @@ -119,8 +119,6 @@ export async function processTradeEvents( await processGateConfigEvents([event]); return; } - return; - } // Validate event type if (event.eventType !== 'KEY_BOUGHT' && event.eventType !== 'KEY_SOLD') { diff --git a/src/modules/keys/key-gate-config.service.ts b/src/modules/keys/key-gate-config.service.ts index 55781675..5a166600 100644 --- a/src/modules/keys/key-gate-config.service.ts +++ b/src/modules/keys/key-gate-config.service.ts @@ -30,7 +30,7 @@ export class KeyNotFoundError extends Error { */ export async function getGateConfig( keyId: string, - now: Date = new Date() + _now: Date = new Date() ): Promise { const creator = await prisma.creatorProfile.findFirst({ where: { OR: [{ id: keyId }, { handle: keyId }] }, From eed15c2baf4e0da2f450b0265b81ccfb4001f100 Mon Sep 17 00:00:00 2001 From: chomo Date: Tue, 20 Oct 2026 12:10:59 +0100 Subject: [PATCH 03/15] fix: remove duplicate import --- src/modules/keys/keys.routes.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/modules/keys/keys.routes.ts b/src/modules/keys/keys.routes.ts index b151dcf4..af0cf001 100644 --- a/src/modules/keys/keys.routes.ts +++ b/src/modules/keys/keys.routes.ts @@ -156,7 +156,6 @@ import { KeyNotFoundError as PriceKeyNotFoundError, } from './key-price.service'; import { getKeyTwap } from './key-twap-window.service'; -import { getKeyTwap } from './key-twap-window.service'; import { getTwapSnapshot } from './key-twap-snapshot.service'; import { DEPTH_DEFAULT_LEVELS, From 35389486978bdde13f952fcb39127024987eafd5 Mon Sep 17 00:00:00 2001 From: chomo Date: Tue, 20 Oct 2026 12:50:08 +0100 Subject: [PATCH 04/15] fix: add missing depth cache constants --- src/constants/redis.constants.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/constants/redis.constants.ts b/src/constants/redis.constants.ts index 95dbd18f..7c2d9923 100644 --- a/src/constants/redis.constants.ts +++ b/src/constants/redis.constants.ts @@ -48,3 +48,11 @@ export const twapSnapshotCacheKey = ( keyId: string, window: TwapWindow ): string => `twap:snapshot:${keyId}:${window}`; + + +// ── Order book depth endpoints (#1152) ─────────────────────────── +export const KEY_DEPTH_CACHE_TTL_SECONDS = 10; + +export const keyDepthCacheKey = (keyId: string): string => `key:depth:${keyId}`; +export const keyDepthSpreadCacheKey = (keyId: string): string => `key:depth:spread:${keyId}`; +export const keyDepthCachePattern = (keyId: string): string => `key:depth:${keyId}*`; From 31ea251e92688cad0cc5916d46acfeb5da267862 Mon Sep 17 00:00:00 2001 From: chomo Date: Tue, 20 Oct 2026 12:54:15 +0100 Subject: [PATCH 05/15] fix: add depth cache constants --- src/constants/redis.constants.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/constants/redis.constants.ts b/src/constants/redis.constants.ts index 7c2d9923..d2308eb2 100644 --- a/src/constants/redis.constants.ts +++ b/src/constants/redis.constants.ts @@ -53,6 +53,7 @@ export const twapSnapshotCacheKey = ( // ── Order book depth endpoints (#1152) ─────────────────────────── export const KEY_DEPTH_CACHE_TTL_SECONDS = 10; -export const keyDepthCacheKey = (keyId: string): string => `key:depth:${keyId}`; +export const keyDepthCacheKey = (keyId: string, levels?: number): string => + levels !== undefined ? `key:depth:${keyId}:${levels}` : `key:depth:${keyId}`; export const keyDepthSpreadCacheKey = (keyId: string): string => `key:depth:spread:${keyId}`; export const keyDepthCachePattern = (keyId: string): string => `key:depth:${keyId}*`; From 7393161713cb50c8cb65db3e81f15e02411f0cc5 Mon Sep 17 00:00:00 2001 From: chomo Date: Tue, 20 Oct 2026 13:27:35 +0100 Subject: [PATCH 06/15] fix: add missing config schema fields from upstream --- src/config.schema.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/config.schema.ts b/src/config.schema.ts index 9bbabbdb..46385f99 100644 --- a/src/config.schema.ts +++ b/src/config.schema.ts @@ -263,7 +263,13 @@ export const envSchema = z LP_CONTRACT_ID: optionalNonEmptyString, // Invoice protocol adapters. Unconfigured reads/execution fail closed. + INVOICE_MARKET_CONTRACT_ID: optionalNonEmptyString, + INVOICE_MARKET_ABI: optionalNonEmptyString, ISSUER_REPUTATION_CONTRACT_ID: optionalNonEmptyString, + INVOICE_FRAUD_CONTRACT_ID: optionalNonEmptyString, + INVOICE_TIMELOCK_CONTRACT_ID: optionalNonEmptyString, + INVOICE_TRANSFER_CONTRACT_ID: optionalNonEmptyString, + // Ownership snapshot cleanup job OWNERSHIP_SNAPSHOT_TABLE_NAME: z @@ -637,3 +643,5 @@ export const envSchema = z }); } }); + + PINATA_GATEWAY_URL: z.string().url().default('https://gateway.pinata.cloud/ipfs'), From 3f62abf664b79bfe9d204b9ea8a4da86dd778a1e Mon Sep 17 00:00:00 2001 From: chomo Date: Sat, 24 Oct 2026 16:20:03 +0100 Subject: [PATCH 07/15] fix: sync config/schema for upstream compatibility --- src/config.schema.ts | 687 ++++++++++++++++++++++++------- src/constants/redis.constants.ts | 4 +- 2 files changed, 533 insertions(+), 158 deletions(-) diff --git a/src/config.schema.ts b/src/config.schema.ts index d9c158ea..d3e65cfb 100644 --- a/src/config.schema.ts +++ b/src/config.schema.ts @@ -1,216 +1,245 @@ -// src/config.schema.ts import { z } from 'zod'; -const booleanCoerce = z - .union([z.boolean(), z.string()]) - .transform(val => { - if (typeof val === 'boolean') return val; - const normalized = val.trim().toLowerCase(); - if (normalized === 'true' || normalized === '1') return true; - if (normalized === 'false' || normalized === '0') return false; - throw new Error(`Invalid boolean value: ${val}`); - }); - -const optionalNonEmptyString = z - .string() - .trim() - .min(1) - .optional() - .or(z.literal('')); - -const chainIdSchema = z.enum(['testnet', 'mainnet']); +/** + * Centralized Zod schema for all environment variables. + * + * Configuration Source Precedence (highest to lowest): + * 1. Environment Variables (.env file or system environment) + * 2. Schema Defaults (defined below with .default()) + * 3. Validation Failure (startup fails if required field missing) + * + * Extracted into its own module so tests can validate the schema + * in isolation (via `.safeParse()`) without triggering the eager + * `envSchema.parse(process.env)` side-effect in `config.ts`. + * + * See docs/configuration.md for complete documentation. + * See docs/CONFIG_SOURCE_PRECEDENCE.md for visual reference. + */ +/** + * Helper to correctly coerce boolean strings from environment variables. + * Zod's default z.coerce.boolean() returns true for any non-empty string, + * including "false" and "0", which is usually not what we want for .env files. + */ +const booleanCoerce = z.preprocess(val => { + if (typeof val === 'string') { + const lower = val.toLowerCase(); + if (lower === 'true' || lower === '1') return true; + if (lower === 'false' || lower === '0') return false; + } + return val; +}, z.coerce.boolean()); + +const optionalNonEmptyString = z.preprocess(val => { + if (typeof val === 'string' && val.trim().length === 0) { + return undefined; + } + + return val; +}, z.string().min(1).optional()); export const envSchema = z .object({ - NODE_ENV: z - .enum(['development', 'test', 'production']) + PORT: z.coerce.number().default(3000), + MODE: z + .enum(['development', 'production', 'test']) .default('development'), - PORT: z.coerce.number().int().positive().default(4000), - MODE: z.enum(['development', 'production']).default('development'), - - DATABASE_URL: z.string().url(), - REDIS_URL: z.string().url(), - REDIS_TTL_SECONDS: z.coerce.number().int().positive().default(60), - JWT_SECRET: z.string().min(32), - STELLAR_NETWORK: chainIdSchema.default('testnet'), - SOROBAN_RPC_URL: z.string().url(), - SOROBAN_RPC_TIMEOUT_MS: z.coerce.number().int().positive().default(5000), - HORIZON_URL: z.string().url(), - HORIZON_TIMEOUT_MS: z.coerce.number().int().positive().default(5000), - - // CORS allowed origins (comma-separated) - CORS_ALLOWED_ORIGINS: z + DATABASE_URL: z .string() - .optional() - .default('http://localhost:3000,http://localhost:4000'), + .min(1, 'DATABASE_URL is required in the environment variables'), + NODE_ID: z.string().default('node-local'), - // Swagger - SWAGGER_ENABLED: booleanCoerce.default(false), - - // Telegram bot (admin notifications) - TELEGRAM_BOT_TOKEN: z.string().optional(), - TELEGRAM_CHAT_ID: z.string().optional(), + GMAIL_USER: z.string(), + GMAIL_APP_PASSWORD: z.string(), + // Google OAuth + GOOGLE_CLIENT_ID: z + .string() + .min(1, 'GOOGLE_CLIENT_ID is required for Google OAuth'), + GOOGLE_CLIENT_SECRET: z + .string() + .min(1, 'GOOGLE_CLIENT_SECRET is required for Google OAuth'), - // Email (Resend) - RESEND_API_KEY: z.string().optional(), - EMAIL_FROM: z.string().email().optional(), - ADMIN_EMAILS: z.string().optional(), + // URLs + BACKEND_URL: z.string().url(), + FRONTEND_URL: z + .string() + .url('FRONTEND_URL must be a valid URL') + .min(1, 'FRONTEND_URL is required'), - // Google OAuth (server-side verification) - GOOGLE_CLIENT_ID: z.string().optional(), - GOOGLE_CLIENT_SECRET: z.string().optional(), - GOOGLE_REDIRECT_URI: z.string().url().optional(), + // Cloudinary + CLOUDINARY_CLOUD_NAME: z + .string() + .min(1, 'CLOUDINARY_CLOUD_NAME is required for image uploads'), + CLOUDINARY_API_KEY: z + .string() + .min(1, 'CLOUDINARY_API_KEY is required for image uploads'), + CLOUDINARY_API_SECRET: z + .string() + .min(1, 'CLOUDINARY_API_SECRET is required for image uploads'), - // Admin allowlist (comma or space separated list of wallet addresses) - ADMIN_WALLETS: z.string().optional(), - TREASURY_WALLETS: z.string().optional(), + PAYSTACK_SECRET_KEY: z + .string() + .min(1, 'PAYSTACK_SECRET_KEY is required for payment processing'), + PAYSTACK_PUBLIC_KEY: optionalNonEmptyString, + ENABLE_RESPONSE_TIMING: booleanCoerce.default(true), + API_VERSION: z.string().default('1.0.0'), + ENABLE_API_VERSION_HEADER: booleanCoerce.default(true), + ENABLE_SCHEMA_VERSION_HEADER: booleanCoerce.default(true), + ENABLE_REQUEST_LOGGING: booleanCoerce.default(true), + DB_QUERY_TIMEOUT_MS: z.coerce.number().default(5000), + DB_POOL_WAIT_WARN_MS: z.coerce.number().int().positive().default(2000), + DB_POOL_WAIT_ERROR_MS: z.coerce.number().int().positive().default(5000), + WEBHOOK_MAX_PER_CREATOR: z.coerce.number().int().positive().default(5), + WEBHOOK_RETRY_MAX_ATTEMPTS: z.coerce.number().int().positive().default(3), + + APP_SECRET: z + .string() + .min(32, 'APP_SECRET should be at least 32 characters') + .default('accesslayer_default_development_secret_key_32_bytes_long'), - // Rate limiting - RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60000), - RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(100), - // Key trading endpoints have stricter defaults - KEY_TRADE_RATE_LIMIT_MAX_REQUESTS: z.coerce + // JWT auth + JWT_SECRET: z + .string() + .min(32, 'JWT_SECRET should be at least 32 characters') + .default('accesslayer_default_development_jwt_secret_key_32_bytes'), + JWT_ISSUER: z.string().default('accesslayer-server'), + JWT_EXPIRES_IN: z.string().default('15m'), + JWT_ACCESS_TOKEN_TTL_SECONDS: z.coerce .number() .int() .positive() - .default(20), - // Auth endpoints have stricter defaults - AUTH_RATE_LIMIT_MAX_REQUESTS: z.coerce + .default(900), + + // Redis cache + REDIS_URL: z.string().default('redis://localhost:6379'), + ENABLE_REDIS_CACHE: booleanCoerce.default(true), + + // Key trade lockup + LOCKUP_DURATION_SECONDS: z.coerce.number().int().nonnegative().default(0), + + // Staking lock period options (#881): comma-separated `name=seconds` + // pairs. Clients pick one by name when staking; the lock expires + // `seconds` after the stake is created. + STAKING_LOCK_PERIOD_OPTIONS: z + .string() + .default( + 'flexible=0,30d=2592000,90d=7776000,180d=15552000,365d=31536000' + ), + + // Protocol revenue distribution (#883): length of each distribution + // cycle in days. Trading fees are aggregated into a pool per cycle and + // claimed proportionally to stake weight (see src/modules/revenue). + REVENUE_DISTRIBUTION_CYCLE_DAYS: z.coerce .number() .int() .positive() - .default(10), + .default(7), + + // Referral programme (#910): share of a referred wallet's first trade + // paid to the referrer, in basis points (500 = 5%). Paid once per + // referee, on their first trade only (see src/modules/referrals). + REFERRAL_REWARD_BPS: z.coerce.number().int().nonnegative().default(500), + + // 2-of-3 admin multisig set for key deprecation (#882). Comma-separated + // Stellar addresses of the admin quorum. When unset, deprecation still + // requires two distinct valid admin signatures but no allowlist is + // enforced (development default). + ADMIN_MULTISIG_WALLETS: optionalNonEmptyString, - // Indexer - INDEXER_ENABLED: booleanCoerce.default(true), - INDEXER_POLL_INTERVAL_MS: z.coerce.number().int().positive().default(5000), - INDEXER_START_LEDGER: z.coerce.number().int().nonnegative().optional(), - INDEXER_CONCURRENCY: z.coerce.number().int().positive().default(1), - INDEXER_MAX_BATCH_SIZE: z.coerce.number().int().positive().default(50), - INDEXER_RETRY_MAX_ATTEMPTS: z.coerce.number().int().positive().default(10), - INDEXER_RETRY_BACKOFF_MS: z.coerce.number().int().positive().default(1000), - INDEXER_HEARTBEAT_INTERVAL_SECONDS: z.coerce + // Leaderboard volume + LEADERBOARD_VOLUME_WINDOW_DAYS: z.coerce .number() .int() .positive() - .default(60), - INDEXER_LAST_PROCESSED_KEY: z.string().default('indexer:last_processed'), - INDEXER_EVENT_TOPIC: z.string().default('contract:event'), - INDEXER_PROCESSED_EVENT_IDS: z.string().default('indexer:processed_ids'), - INDEXER_EVENT_TTL_SECONDS: z.coerce.number().int().positive().default(86400), + .default(7), + LEADERBOARD_VOLUME_CACHE_TTL_SECONDS: z.coerce + .number() + .int() + .positive() + .default(300), - // Indexer backpressure / health - INDEXER_BACKPRESSURE_QUEUE_HIGH_WATERMARK: z.coerce + INDEXER_JITTER_FACTOR: z.coerce.number().min(0).max(1).default(0.1), + BACKGROUND_JOB_LOCK_TTL_MS: z.coerce .number() .int() .positive() - .default(1000), - INDEXER_BACKPRESSURE_SLOWDOWN_DELAY_MS: z.coerce + .default(300000), + SLOW_QUERY_THRESHOLD_MS: z.coerce.number().int().positive().default(500), + CREATOR_LIST_SLOW_QUERY_THRESHOLD_MS: z.coerce .number() .int() .positive() .default(500), - INDEXER_HEALTH_ALERT_THRESHOLD_FAILURES: z.coerce + INDEXER_CURSOR_STALE_AGE_WARNING_MS: z.coerce .number() .int() .positive() - .default(10), - INDEXER_LOG_VERBOSE: booleanCoerce.default(false), - - // Feature flags - FEATURE_DAILY_REWARDS_ENABLED: booleanCoerce.default(true), - FEATURE_WALLET_SEARCH_ENABLED: booleanCoerce.default(true), - - // Reputation scoring - REPUTATION_SCORING_ENABLED: booleanCoerce.default(true), - REPUTATION_REFRESH_INTERVAL_HOURS: z.coerce + .default(300000), + INDEXER_HEARTBEAT_STALE_THRESHOLD_MS: z.coerce .number() - .int() .positive() - .default(24), + .default(300000), - // Export/CSV - EXPORT_MAX_ROWS: z.coerce.number().int().positive().default(10000), + // Indexer feature flags + ENABLE_INDEXER_DEDUPE: booleanCoerce.default(true), + ENABLE_INDEXER_DLQ: booleanCoerce.default(true), + ENABLE_INDEXER_CURSOR_STALENESS_WARNING: booleanCoerce.default(true), - // Idempotency - IDEMPOTENCY_ENABLED: booleanCoerce.default(true), - IDEMPOTENCY_TTL_SECONDS: z.coerce.number().int().positive().default(300), - IDEMPOTENCY_HEADER_NAME: z.string().default('Idempotency-Key'), - IDEMPOTENCY_MAX_BODY_BYTES: z.coerce.number().int().positive().default(1048576), + // Stellar auth — optional server keypair secret used for SEP-10 challenge + // signing. When absent the server falls back to an ephemeral random keypair. + STELLAR_AUTH_SECRET: optionalNonEmptyString, - // Daily rewards cron - DAILY_REWARDS_CRON_ENABLED: booleanCoerce.default(true), - DAILY_REWARDS_CRON_SCHEDULE: z.string().default('0 0 * * *'), - DAILY_REWARDS_JOB_MAX_RETRIES: z.coerce.number().int().positive().default(3), - DAILY_REWARDS_JOB_RETRY_DELAY_MS: z.coerce + // Stellar network + STELLAR_NETWORK: z + .enum(['testnet', 'mainnet'], { + message: + 'STELLAR_NETWORK must be "testnet" or "mainnet". Set STELLAR_NETWORK in your .env file.', + }) + .default('testnet'), + STELLAR_HORIZON_URL: z + .string() + .url( + 'STELLAR_HORIZON_URL must be a valid URL (e.g. https://horizon-testnet.stellar.org)' + ) + .default('https://horizon-testnet.stellar.org'), + STELLAR_SOROBAN_RPC_URL: z + .string() + .url( + 'STELLAR_SOROBAN_RPC_URL must be a valid URL (e.g. https://soroban-testnet.stellar.org)' + ) + .default('https://soroban-testnet.stellar.org'), + + // Soroban contract interaction service (#899): submission retries with + // exponential backoff, confirmation polling, and resolution events. + SOROBAN_SUBMIT_MAX_ATTEMPTS: z.coerce .number() .int() .positive() - .default(5000), - - // Referral tier tracking cron (#1034) - REFERRAL_TIER_CRON_ENABLED: booleanCoerce.default(true), - REFERRAL_TIER_EVALUATION_SCHEDULE: z.string().default('0 3 * * *'), - REFERRAL_TIER_BATCH_SIZE: z.coerce.number().int().positive().default(200), - REFERRAL_TIER_CONCURRENCY: z.coerce.number().int().positive().default(4), - - // Campaign evaluation and expiry cron (#campaign-cron) - CAMPAIGN_CRON_ENABLED: booleanCoerce.default(true), - CAMPAIGN_EVALUATION_INTERVAL_HOURS: z.coerce + .default(3), + SOROBAN_SUBMIT_BASE_DELAY_MS: z.coerce .number() .int() .positive() - .default(24), - CAMPAIGN_END_CHECK_INTERVAL_MINUTES: z.coerce + .default(1000), + SOROBAN_SUBMIT_MAX_DELAY_MS: z.coerce .number() .int() .positive() - .default(60), - CAMPAIGN_QUALIFICATION_BATCH_SIZE: z.coerce + .default(15000), + SOROBAN_POLL_INTERVAL_MS: z.coerce .number() .int() .positive() - .default(100), - CAMPAIGN_QUALIFICATION_CONCURRENCY: z.coerce + .default(5000), + SOROBAN_POLL_TIMEOUT_MS: z.coerce .number() .int() .positive() - .default(5), - - // Key NFT badge minting trigger service (#1148). Minting is disabled - // until both the contract id and the server minter secret are set. - BADGE_CONTRACT_ID: optionalNonEmptyString, - BADGE_MINTER_SECRET: optionalNonEmptyString, - // Single-swap size (XLM) that earns the WHALE_HOLDER badge. - BADGE_WHALE_THRESHOLD_XLM: z.coerce.number().positive().default(10000), - // Continuous hold (days) that earns the DIAMOND_HANDS badge. - BADGE_HOLD_MILESTONE_DAYS: z.coerce.number().int().positive().default(365), - // Highest leaderboard rank (inclusive) that earns the TOP_TRADER badge. - BADGE_TOP_TRADER_MAX_RANK: z.coerce.number().int().positive().default(10), - // Max mint attempts before a transient failure is treated as terminal. - BADGE_MINT_MAX_ATTEMPTS: z.coerce.number().int().positive().default(3), - // Slow request threshold (#754): warn-level log for requests exceeding this duration - SLOW_REQUEST_THRESHOLD_MS: z.coerce.number().int().positive().default(2000), - - // Contract addresses - KEY_CONTRACT_ID: optionalNonEmptyString, - FEE_DISTRIBUTOR_CONTRACT_ID: optionalNonEmptyString, - TREASURY_CONTRACT_ID: optionalNonEmptyString, - REVENUE_CONTRACT_ID: optionalNonEmptyString, - REFERRAL_CONTRACT_ID: optionalNonEmptyString, - - // Native XLM SAC contract id on Soroban - NATIVE_SAC_CONTRACT_ID: z - .string() - .trim() - .min(1) - .default('CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC'), + .default(120000), // Circuit breaker (#987): deployed contract id holding the per-key // max_bps configuration, and the TTL (seconds) for the cached contract // read. Defaults to a 5-minute refresh window. CIRCUIT_BREAKER_CONTRACT_ID: optionalNonEmptyString, - GATE_CONFIG_CONTRACT_ID: optionalNonEmptyString, CIRCUIT_BREAKER_CONFIG_CACHE_TTL_SECONDS: z.coerce .number() .int() @@ -224,6 +253,7 @@ export const envSchema = z .positive() .default(60), + // Dynamic fee contract (#1055) DYNAMIC_FEE_CONTRACT_ID: optionalNonEmptyString, @@ -286,6 +316,351 @@ export const envSchema = z .int() .positive() .default(60), + + // Session activity logging + anomaly detection (#1035) + SESSION_ACTIVITY_LOGGING_ENABLED: booleanCoerce.default(true), + SESSION_ACTIVITY_RETENTION_DAYS: z.coerce + .number() + .int() + .positive() + .default(30), + SESSION_ACTIVITY_CLEANUP_ENABLED: booleanCoerce.default(true), + SESSION_ACTIVITY_CLEANUP_INTERVAL_MINUTES: z.coerce + .number() + .int() + .positive() + .default(60), + SESSION_ANOMALY_RATE_WINDOW_MINUTES: z.coerce + .number() + .int() + .positive() + .default(5), + SESSION_ANOMALY_RATE_THRESHOLD: z.coerce + .number() + .int() + .positive() + .default(120), + SESSION_ANOMALY_UNUSUAL_HOUR_START: z.coerce + .number() + .int() + .min(0) + .max(23) + .default(1), + SESSION_ANOMALY_UNUSUAL_HOUR_END: z.coerce + .number() + .int() + .min(0) + .max(23) + .default(5), + + // Price movement detection job (feeds price_moved notifications) + DETECT_PRICE_MOVEMENTS_ENABLED: booleanCoerce.default(true), + DETECT_PRICE_MOVEMENTS_INTERVAL_MINUTES: z.coerce + .number() + .int() + .positive() + .default(5), + + // TWAP computation job (#963) — recomputes 1h/4h/24h TWAP per active key. + TWAP_COMPUTATION_ENABLED: booleanCoerce.default(true), + TWAP_COMPUTATION_INTERVAL_MINUTES: z.coerce + .number() + .int() + .positive() + .default(5), + + // Minimum number of observations a TWAPUpdated snapshot must carry for + // GET /keys/:key_id/twap to treat it as valid (#1082). Below this the + // endpoint responds with TWAPInvalid instead of a price. + TWAP_MIN_OBSERVATIONS: z.coerce.number().int().positive().default(3), + + // Governance proposal sync job + GOVERNANCE_SYNC_ENABLED: booleanCoerce.default(false), + GOVERNANCE_SYNC_INTERVAL_MINUTES: z.coerce + .number() + .int() + .positive() + .default(5), + + // Key sunset watch (#931): number of consecutive inactive days before a + // key is considered "near threshold" and surfaced by GET /keys/sunset-watch. + // Keys whose on-chain KeySunsetFlagged event has been processed always + // appear regardless of this threshold. Defaults to 30 days. + KEY_SUNSET_INACTIVITY_THRESHOLD_DAYS: z.coerce + .number() + .int() + .positive() + .default(30), + + // Request body size limits (see docs/body-size-limits.md). + // Accepts any size string understood by the `bytes` package used + // internally by body-parser (e.g. '100kb', '1mb', '10mb'). + BODY_SIZE_LIMIT_DEFAULT: z.string().min(1).default('10mb'), + BODY_SIZE_LIMIT_AUTH: optionalNonEmptyString, + BODY_SIZE_LIMIT_ADMIN: optionalNonEmptyString, + BODY_SIZE_LIMIT_CREATORS: optionalNonEmptyString, + + // Distributed tracing + // Shared secret trusted internal callers present in the + // `x-internal-service-token` header to have their incoming + // `X-Trace-Id` header honored instead of a freshly generated one. + // Left unset by default, so no caller is trusted unless configured. + TRACE_ID_TRUSTED_TOKEN: optionalNonEmptyString, + INTERNAL_SERVICE_KEY: optionalNonEmptyString, + + // Query cost governor (#755): rolling per-wallet (or per-IP, when + // unauthenticated) database query budget. See + // src/middlewares/query-cost-governor.middleware.ts. + QUERY_COST_BUDGET: z.coerce.number().int().positive().default(200), + QUERY_COST_WINDOW_MS: z.coerce.number().int().positive().default(60_000), + // JSON object overriding/extending the default route->cost map in + // src/constants/query-cost.constants.ts, e.g. + // '{"GET /search": 8, "GET /custom-route": 2}'. Merged over the + // defaults, not a full replacement, so operators only need to + // specify what differs. + QUERY_COST_MAP_JSON: optionalNonEmptyString, + // Comma-separated wallet addresses that bypass the governor entirely. + QUERY_COST_ADMIN_WALLETS: optionalNonEmptyString, + HORIZON_WEBHOOK_SECRET: optionalNonEmptyString, + WEBHOOK_RETRY_BASE_DELAY_MS: z.coerce + .number() + .int() + .positive() + .default(1000), + SSE_HEARTBEAT_INTERVAL_MS: z.coerce + .number() + .int() + .positive() + .default(15000), + SSE_QUEUE_CAPACITY: z.coerce.number().int().positive().default(1000), + SSE_QUEUE_FULL_TIMEOUT_MS: z.coerce + .number() + .int() + .positive() + .default(5000), + SSE_REPLAY_MAX_EVENTS: z.coerce.number().int().positive().default(100), + + // SSE subscription management (src/modules/subscriptions) — a wallet's + // subscription set, persisted in Redis, distinct from the per-connection + // heartbeat/queue/replay tuning above. + SSE_MAX_CONNECTIONS_PER_WALLET: z.coerce + .number() + .int() + .positive() + .default(10), + SSE_SUBSCRIPTION_TTL_MS: z.coerce + .number() + .int() + .positive() + .default(300000), + SSE_MAX_SUBSCRIPTIONS_PER_WALLET: z.coerce + .number() + .int() + .positive() + .default(10), + SSE_THROTTLE_DURATION_MS: z.coerce + .number() + .int() + .positive() + .default(1000), + + // Oracle price feed staleness threshold and cache TTL. + // ORACLE_STALENESS_THRESHOLD_MS: how old an oracle price can be before + // the response includes `isStale: true`. Default: 5 minutes. + // ORACLE_CACHE_TTL_MS: how long the oracle-price response is cached in + // Redis. Should match the typical oracle update frequency. Default: 30 s. + ORACLE_STALENESS_THRESHOLD_MS: z.coerce + .number() + .int() + .positive() + .default(300_000), + ORACLE_CACHE_TTL_MS: z.coerce.number().int().positive().default(30_000), + + // Flash loan guard (#938): violations indexed from + // FlashLoanGuardTriggered contract events. A wallet that reaches + // FLASH_LOAN_VIOLATION_THRESHOLD uncleared violations is alerted and, + // when FLASH_LOAN_AUTO_SUSPEND_ENABLED is true, suspended for + // FLASH_LOAN_SUSPENSION_DURATION_HOURS (0 = indefinite, lifted once the + // violation history ages out). Violations older than + // FLASH_LOAN_VIOLATION_COOLDOWN_HOURS stop counting towards the + // threshold and are cleared by the cleanup job. + FLASH_LOAN_VIOLATION_THRESHOLD: z.coerce + .number() + .int() + .positive() + .default(3), + FLASH_LOAN_AUTO_SUSPEND_ENABLED: booleanCoerce.default(true), + FLASH_LOAN_VIOLATION_COOLDOWN_HOURS: z.coerce + .number() + .int() + .positive() + .default(24), + FLASH_LOAN_SUSPENSION_DURATION_HOURS: z.coerce + .number() + .int() + .nonnegative() + .default(0), + FLASH_LOAN_CLEANUP_ENABLED: booleanCoerce.default(true), + FLASH_LOAN_CLEANUP_INTERVAL_MINUTES: z.coerce + .number() + .int() + .positive() + .default(60), + + // Creator key on-chain metadata sync (#986) + METADATA_STALENESS_THRESHOLD_MS: z.coerce + .number() + .int() + .positive() + .default(10 * 60 * 1000), // 10 minutes + + // Multi-tier circuit breaker configuration (#726) + CIRCUIT_BREAKER_FAILURE_THRESHOLD: z.coerce + .number() + .int() + .positive() + .default(5), + CIRCUIT_BREAKER_RATE_THRESHOLD: z.coerce + .number() + .min(1) + .max(100) + .default(50), + CIRCUIT_BREAKER_COOLDOWN_SECONDS: z.coerce + .number() + .int() + .positive() + .default(30), + CIRCUIT_BREAKER_WINDOW_SECONDS: z.coerce + .number() + .int() + .positive() + .default(60), + + // Per-dependency overrides for Soroban RPC + CIRCUIT_BREAKER_SOROBAN_RPC_FAILURE_THRESHOLD: z.coerce + .number() + .int() + .positive() + .optional(), + CIRCUIT_BREAKER_SOROBAN_RPC_RATE_THRESHOLD: z.coerce + .number() + .min(1) + .max(100) + .optional(), + CIRCUIT_BREAKER_SOROBAN_RPC_COOLDOWN_SECONDS: z.coerce + .number() + .int() + .positive() + .optional(), + CIRCUIT_BREAKER_SOROBAN_RPC_WINDOW_SECONDS: z.coerce + .number() + .int() + .positive() + .optional(), + + // Per-dependency overrides for Horizon + CIRCUIT_BREAKER_HORIZON_FAILURE_THRESHOLD: z.coerce + .number() + .int() + .positive() + .optional(), + CIRCUIT_BREAKER_HORIZON_RATE_THRESHOLD: z.coerce + .number() + .min(1) + .max(100) + .optional(), + CIRCUIT_BREAKER_HORIZON_COOLDOWN_SECONDS: z.coerce + .number() + .int() + .positive() + .optional(), + CIRCUIT_BREAKER_HORIZON_WINDOW_SECONDS: z.coerce + .number() + .int() + .positive() + .optional(), + + // Per-dependency overrides for Redis + CIRCUIT_BREAKER_REDIS_FAILURE_THRESHOLD: z.coerce + .number() + .int() + .positive() + .optional(), + CIRCUIT_BREAKER_REDIS_RATE_THRESHOLD: z.coerce + .number() + .min(1) + .max(100) + .optional(), + CIRCUIT_BREAKER_REDIS_COOLDOWN_SECONDS: z.coerce + .number() + .int() + .positive() + .optional(), + CIRCUIT_BREAKER_REDIS_WINDOW_SECONDS: z.coerce + .number() + .int() + .positive() + .optional(), + + // Per-dependency overrides for Database connection pool + CIRCUIT_BREAKER_DATABASE_FAILURE_THRESHOLD: z.coerce + .number() + .int() + .positive() + .optional(), + CIRCUIT_BREAKER_DATABASE_RATE_THRESHOLD: z.coerce + .number() + .min(1) + .max(100) + .optional(), + CIRCUIT_BREAKER_DATABASE_COOLDOWN_SECONDS: z.coerce + .number() + .int() + .positive() + .optional(), + CIRCUIT_BREAKER_DATABASE_WINDOW_SECONDS: z.coerce + .number() + .int() + .positive() + .optional(), + + // Campaign evaluation and expiry cron (#campaign-cron) + CAMPAIGN_CRON_ENABLED: booleanCoerce.default(true), + CAMPAIGN_EVALUATION_INTERVAL_HOURS: z.coerce + .number() + .int() + .positive() + .default(24), + CAMPAIGN_END_CHECK_INTERVAL_MINUTES: z.coerce + .number() + .int() + .positive() + .default(60), + CAMPAIGN_QUALIFICATION_BATCH_SIZE: z.coerce + .number() + .int() + .positive() + .default(100), + CAMPAIGN_QUALIFICATION_CONCURRENCY: z.coerce + .number() + .int() + .positive() + .default(5), + + // Key NFT badge minting trigger service (#1148). Minting is disabled + // until both the contract id and the server minter secret are set. + BADGE_CONTRACT_ID: optionalNonEmptyString, + BADGE_MINTER_SECRET: optionalNonEmptyString, + // Single-swap size (XLM) that earns the WHALE_HOLDER badge. + BADGE_WHALE_THRESHOLD_XLM: z.coerce.number().positive().default(10000), + // Continuous hold (days) that earns the DIAMOND_HANDS badge. + BADGE_HOLD_MILESTONE_DAYS: z.coerce.number().int().positive().default(365), + // Highest leaderboard rank (inclusive) that earns the TOP_TRADER badge. + BADGE_TOP_TRADER_MAX_RANK: z.coerce.number().int().positive().default(10), + // Max mint attempts before a transient failure is treated as terminal. + BADGE_MINT_MAX_ATTEMPTS: z.coerce.number().int().positive().default(3), + // Slow request threshold (#754): warn-level log for requests exceeding this duration + SLOW_REQUEST_THRESHOLD_MS: z.coerce.number().int().positive().default(2000), }) .superRefine((data, ctx) => { if (data.MODE === 'production' && data.STELLAR_NETWORK === 'testnet') { @@ -297,5 +672,3 @@ export const envSchema = z }); } }); - -export type Env = z.infer; diff --git a/src/constants/redis.constants.ts b/src/constants/redis.constants.ts index 75639984..d1493e20 100644 --- a/src/constants/redis.constants.ts +++ b/src/constants/redis.constants.ts @@ -1,7 +1,9 @@ // src/constants/redis.constants.ts // TWAP cache (#963): get_twap returns per (keyId, window, side); TTL 30s. -import { TwapWindow } from '../modules/keys/keys.types'; +import type { TwapWindow } from '../modules/keys/key-twap-window.service'; + +export const TWAP_WINDOWS = ['1h', '4h', '24h'] as const; export const TWAP_CACHE_TTL_SECONDS = 30; From 387a37989aef70e8db8f297681180a5291e0760f Mon Sep 17 00:00:00 2001 From: chomo Date: Sat, 24 Oct 2026 16:32:19 +0100 Subject: [PATCH 08/15] fix: make config schema passthrough for compatibility --- src/config.schema.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config.schema.ts b/src/config.schema.ts index d3e65cfb..836c398e 100644 --- a/src/config.schema.ts +++ b/src/config.schema.ts @@ -662,6 +662,7 @@ 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() .superRefine((data, ctx) => { if (data.MODE === 'production' && data.STELLAR_NETWORK === 'testnet') { ctx.addIssue({ From 1d981b272b29dffe5d6ad866fa184d7ca7b34977 Mon Sep 17 00:00:00 2001 From: chomo Date: Sat, 24 Oct 2026 16:35:35 +0100 Subject: [PATCH 09/15] fix: catchall in config schema --- src/config.schema.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config.schema.ts b/src/config.schema.ts index 836c398e..75b92c40 100644 --- a/src/config.schema.ts +++ b/src/config.schema.ts @@ -663,6 +663,7 @@ export const envSchema = z 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({ From 9337a05aaa7b8307ace03d476048c251ad3d9f92 Mon Sep 17 00:00:00 2001 From: chomo Date: Sat, 24 Oct 2026 16:39:20 +0100 Subject: [PATCH 10/15] fix: add twap exports for compatibility --- src/constants/redis.constants.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/constants/redis.constants.ts b/src/constants/redis.constants.ts index d1493e20..4695a63f 100644 --- a/src/constants/redis.constants.ts +++ b/src/constants/redis.constants.ts @@ -1,3 +1,4 @@ +export type { TwapWindow } from '../modules/keys/key-twap-window.service'; // src/constants/redis.constants.ts // TWAP cache (#963): get_twap returns per (keyId, window, side); TTL 30s. @@ -5,6 +6,10 @@ import type { TwapWindow } from '../modules/keys/key-twap-window.service'; export const TWAP_WINDOWS = ['1h', '4h', '24h'] as const; +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 TWAP_CACHE_TTL_SECONDS = 30; export const twapRedisKey = (keyId: string, window: TwapWindow, side: string): string => From 316d91369d41024e5afb21f2ed6b8f6e0201b183 Mon Sep 17 00:00:00 2001 From: chomo Date: Sat, 24 Oct 2026 16:42:20 +0100 Subject: [PATCH 11/15] fix: align twap windows --- src/constants/redis.constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/constants/redis.constants.ts b/src/constants/redis.constants.ts index 4695a63f..8a66c43d 100644 --- a/src/constants/redis.constants.ts +++ b/src/constants/redis.constants.ts @@ -4,7 +4,7 @@ export type { TwapWindow } from '../modules/keys/key-twap-window.service'; // 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_WINDOWS = ['1h', '4h', '24h'] as const; +export const TWAP_WINDOWS = ['1h', '4h', '24h', '7d'] as const; export const TWAP_STALE_THRESHOLD_MS = 120_000; export const TWAP_MAX_SNAPSHOTS = 1000; From 1cf505229c4bf3da02b8e93ea1cb316b82dbe5cc Mon Sep 17 00:00:00 2001 From: chomo Date: Sat, 24 Oct 2026 16:46:44 +0100 Subject: [PATCH 12/15] fix: align twap window keys --- src/modules/keys/key-twap-window.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/keys/key-twap-window.service.ts b/src/modules/keys/key-twap-window.service.ts index 9b192518..1acec377 100644 --- a/src/modules/keys/key-twap-window.service.ts +++ b/src/modules/keys/key-twap-window.service.ts @@ -5,7 +5,7 @@ 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 = { From 957b3898890a2b2704ac2118a52434acd8345eec Mon Sep 17 00:00:00 2001 From: chomo Date: Sat, 24 Oct 2026 16:52:53 +0100 Subject: [PATCH 13/15] fix: add 4h twap window values --- src/modules/keys/key-twap-window.service.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/modules/keys/key-twap-window.service.ts b/src/modules/keys/key-twap-window.service.ts index 1acec377..99671a62 100644 --- a/src/modules/keys/key-twap-window.service.ts +++ b/src/modules/keys/key-twap-window.service.ts @@ -10,6 +10,7 @@ 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, }; From d72e4d49829fb9426e4b6fcb55908f5674e3a0f0 Mon Sep 17 00:00:00 2001 From: chomo Date: Sat, 24 Oct 2026 17:09:03 +0100 Subject: [PATCH 14/15] fix: make twapRedisKey backward compatible --- src/constants/redis.constants.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/constants/redis.constants.ts b/src/constants/redis.constants.ts index 8a66c43d..9c97fb3a 100644 --- a/src/constants/redis.constants.ts +++ b/src/constants/redis.constants.ts @@ -12,8 +12,8 @@ export { TWAP_WINDOW_MS } from '../modules/keys/key-twap-window.service'; export const TWAP_CACHE_TTL_SECONDS = 30; -export const twapRedisKey = (keyId: string, window: TwapWindow, side: string): string => - `twap:${keyId}:${window}:${side}`; +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), From 4ac28b367218db73d6d53c4eb884ad2b405a6a10 Mon Sep 17 00:00:00 2001 From: chomo Date: Mon, 26 Oct 2026 10:29:55 +0100 Subject: [PATCH 15/15] fix: twap cache ttl type --- src/constants/redis.constants.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/constants/redis.constants.ts b/src/constants/redis.constants.ts index 9c97fb3a..362682d5 100644 --- a/src/constants/redis.constants.ts +++ b/src/constants/redis.constants.ts @@ -10,7 +10,12 @@ 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 TWAP_CACHE_TTL_SECONDS = 30; +export const TWAP_CACHE_TTL_SECONDS: Record = { + '1h': 30, + '4h': 30, + '24h': 30, + '7d': 30, +} as any; export const twapRedisKey = (keyId: string, window: TwapWindow, side?: string): string => side === undefined ? `twap:${keyId}:${window}` : `twap:${keyId}:${window}:${side}`;