From ea1a6bdac1392245a50254ae3c34e61bcd2c6113 Mon Sep 17 00:00:00 2001 From: Stanley Owoh Date: Sat, 17 Oct 2026 01:55:07 +0100 Subject: [PATCH] feat: fix creator key cooldown --- src/middlewares/buy-cooldown.middleware.ts | 54 +++++++ src/modules/creator/buy.controller.ts | 2 + src/modules/creators/creators.routes.ts | 2 + src/modules/keys/key-cooldown.service.ts | 170 ++++++++++++++++++++- 4 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 src/middlewares/buy-cooldown.middleware.ts diff --git a/src/middlewares/buy-cooldown.middleware.ts b/src/middlewares/buy-cooldown.middleware.ts new file mode 100644 index 00000000..18250a33 --- /dev/null +++ b/src/middlewares/buy-cooldown.middleware.ts @@ -0,0 +1,54 @@ +import type { NextFunction, Response } from 'express'; +import { ErrorCode } from '../constants/error.constants'; +import { logger } from '../utils/logger.utils'; +import { getBuyCooldownStatus } from '../modules/keys/key-cooldown.service'; +import type { StellarSignedRequest } from './stellar-signature.middleware'; + +export function enforceBuyCooldown() { + return async ( + req: StellarSignedRequest, + res: Response, + next: NextFunction + ): Promise => { + const walletAddress = req.walletAddress; + const keyId = req.params?.id ?? req.params?.keyId ?? req.params?.creatorId; + + if (!walletAddress || !keyId) { + next(); + return; + } + + const resolvedKeyId = Array.isArray(keyId) ? String(keyId[0]) : String(keyId); + + try { + const status = await getBuyCooldownStatus(resolvedKeyId, walletAddress); + if (!status.cooldownActive || !status.cooldownExpiresAt) { + next(); + return; + } + + const retryAfterSeconds = Math.max(1, status.remainingSeconds); + res.set('Retry-After', String(retryAfterSeconds)); + res.status(429).json({ + success: false, + error: { + code: ErrorCode.RATE_LIMIT, + message: 'Wallet is still within the buy cooldown for this key', + }, + cooldown_expires_at: status.cooldownExpiresAt, + }); + return; + } catch (error) { + logger.warn( + { + type: 'buy_cooldown_check_failed', + walletAddress, + keyId: resolvedKeyId, + error: error instanceof Error ? error.message : String(error), + }, + 'Buy cooldown check failed; allowing request through' + ); + next(); + } + }; +} diff --git a/src/modules/creator/buy.controller.ts b/src/modules/creator/buy.controller.ts index ee630540..8e493655 100644 --- a/src/modules/creator/buy.controller.ts +++ b/src/modules/creator/buy.controller.ts @@ -16,6 +16,7 @@ import { import { getKeyFees } from '../keys/key-fees.service'; import { prisma } from '../../utils/prisma.utils'; import { getBuyUnitPrice } from '../../utils/pricing.utils'; +import { setBuyCooldownForWallet } from '../keys/key-cooldown.service'; import { logSlippageRejection, sendSlippageExceeded, @@ -132,5 +133,6 @@ export async function httpBuyCreatorKey( creatorId: keyId, quantity: body.quantity, }); + await setBuyCooldownForWallet(keyId, walletAddress); sendSuccess(res, result, 200); } diff --git a/src/modules/creators/creators.routes.ts b/src/modules/creators/creators.routes.ts index 08ffe189..a6a367d1 100644 --- a/src/modules/creators/creators.routes.ts +++ b/src/modules/creators/creators.routes.ts @@ -25,6 +25,7 @@ import { validateCreatorParam } from '../../middlewares/creator-param.middleware import { requireCreatorProfileOwnership } from '../../middlewares/wallet-ownership.middleware'; import { requireStellarSignature } from '../../middlewares/stellar-signature.middleware'; import { buyKeyRateLimit } from '../../middlewares/wallet-rate-limit.middleware'; +import { enforceBuyCooldown } from '../../middlewares/buy-cooldown.middleware'; import { validateBody } from '../../middlewares/validate-body.middleware'; import { httpBuyCreatorKey, buySchema } from '../creator/buy.controller'; import { httpSellCreatorKey, sellSchema } from '../creator/sell.controller'; @@ -59,6 +60,7 @@ creatorsRouter.post( platformPauseGuard(), requireStellarSignature(), buyKeyRateLimit, + enforceBuyCooldown(), validateBody(buySchema), httpBuyCreatorKey ); diff --git a/src/modules/keys/key-cooldown.service.ts b/src/modules/keys/key-cooldown.service.ts index 7ccc592d..b69bc163 100644 --- a/src/modules/keys/key-cooldown.service.ts +++ b/src/modules/keys/key-cooldown.service.ts @@ -2,11 +2,18 @@ // Buy cooldown status for a wallet on a key (#874, #968). import { prisma } from '../../utils/prisma.utils'; -import { cacheGetJson, cacheSetJson, cacheInvalidate } from '../../utils/redis.utils'; +import { + cacheGetJson, + cacheSetJson, + cacheInvalidate, + getRedis, +} from '../../utils/redis.utils'; +import { logger } from '../../utils/logger.utils'; import { KeyNotFoundError } from './key-fees.service'; /** Approximate seconds per Stellar ledger. */ const SECONDS_PER_LEDGER = 5; +const BUY_COOLDOWN_REDIS_KEY_PREFIX = 'buy-cooldown'; export interface KeyCooldownStatus { key_id: string; @@ -24,6 +31,167 @@ export interface KeyCooldownStatus { export const cooldownCacheKey = (keyId: string, wallet: string): string => `keys:cooldown:${keyId}:${wallet.toLowerCase()}`; +/** Redis key holding the absolute buy-cooldown expiry for a wallet on a key. */ +export function buildBuyCooldownRedisKey( + keyId: string, + walletAddress: string +): string { + return `${BUY_COOLDOWN_REDIS_KEY_PREFIX}:${walletAddress.toLowerCase()}:${keyId.toLowerCase()}`; +} + +/** + * Resolves the buy cooldown duration for a key, in seconds. The duration is + * configured per key through contract metadata, mirrored off-chain on + * `CreatorProfile.cooldownLedgers` (in ledgers). Zero (or an unknown key) + * disables the cooldown. + */ +export async function getCooldownDurationSeconds( + keyId: string +): Promise { + const creator = await prisma.creatorProfile.findFirst({ + where: { OR: [{ id: keyId }, { handle: keyId }] }, + select: { id: true, cooldownLedgers: true }, + }); + if (!creator) { + throw new KeyNotFoundError(keyId); + } + return Math.max(0, creator.cooldownLedgers * SECONDS_PER_LEDGER); +} + +/** + * Records a successful buy by storing the cooldown expiry in Redis keyed by + * wallet address and key ID. The TTL matches the remaining cooldown so the key + * self-expires exactly when the cooldown ends. Returns the ISO expiry, or + * `null` when no cooldown is configured or the write failed. + */ +export async function setBuyCooldownForWallet( + keyId: string, + walletAddress: string +): Promise { + try { + const durationSeconds = await getCooldownDurationSeconds(keyId); + if (durationSeconds <= 0) { + return null; + } + + const expiresAt = new Date( + Date.now() + durationSeconds * 1000 + ).toISOString(); + + const redis = getRedis(); + if (!redis) { + return expiresAt; + } + + await redis.set( + buildBuyCooldownRedisKey(keyId, walletAddress), + expiresAt, + 'EX', + durationSeconds + ); + return expiresAt; + } catch (error) { + logger.warn( + { + type: 'buy_cooldown_set_failed', + keyId, + walletAddress, + error: error instanceof Error ? error.message : String(error), + }, + 'Failed to persist buy cooldown in Redis' + ); + return null; + } +} + +/** + * Returns the active buy cooldown for a wallet on a key. Reads the Redis key + * first; when Redis is unavailable, the key is missing, or the read fails, the + * cooldown is recomputed from the database (indexed trade ledger vs. indexed + * head) so enforcement still works during a Redis outage. + */ +export async function getBuyCooldownStatus( + keyId: string, + walletAddress: string +): Promise<{ + cooldownActive: boolean; + cooldownExpiresAt: string | null; + remainingSeconds: number; +}> { + const redis = getRedis(); + const redisKey = buildBuyCooldownRedisKey(keyId, walletAddress); + + if (redis) { + try { + const cachedExpiry = await redis.get(redisKey); + if (cachedExpiry) { + const expiry = new Date(cachedExpiry); + const remainingMs = expiry.getTime() - Date.now(); + if (!Number.isNaN(expiry.getTime()) && remainingMs > 0) { + return { + cooldownActive: true, + cooldownExpiresAt: expiry.toISOString(), + remainingSeconds: Math.ceil(remainingMs / 1000), + }; + } + await redis.del(redisKey); + } + } catch (error) { + logger.warn( + { + type: 'buy_cooldown_redis_read_failed', + keyId, + walletAddress, + error: error instanceof Error ? error.message : String(error), + }, + 'Redis buy cooldown read failed; falling back to database' + ); + } + } + + const dbCooldown = await getKeyCooldown(keyId, walletAddress); + if (!dbCooldown.cooldown_active || !dbCooldown.unlock_estimated_at) { + return { + cooldownActive: false, + cooldownExpiresAt: null, + remainingSeconds: 0, + }; + } + + const cooldownExpiresAt = new Date( + dbCooldown.unlock_estimated_at + ).toISOString(); + + const redisFallback = getRedis(); + if (redisFallback) { + try { + const remainingSeconds = Math.max(1, dbCooldown.remaining_seconds); + await redisFallback.set( + buildBuyCooldownRedisKey(keyId, walletAddress), + cooldownExpiresAt, + 'EX', + remainingSeconds + ); + } catch (error) { + logger.warn( + { + type: 'buy_cooldown_cache_write_failed', + keyId, + walletAddress, + error: error instanceof Error ? error.message : String(error), + }, + 'Failed to warm Redis cooldown cache after database fallback' + ); + } + } + + return { + cooldownActive: true, + cooldownExpiresAt, + remainingSeconds: dbCooldown.remaining_seconds, + }; +} + /** * Returns the buy cooldown status for a wallet on a key. * Cached in Redis with TTL equal to the remaining cooldown seconds (expires precisely when cooldown ends).