Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions src/middlewares/buy-cooldown.middleware.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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();
}
};
}
2 changes: 2 additions & 0 deletions src/modules/creator/buy.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -132,5 +133,6 @@ export async function httpBuyCreatorKey(
creatorId: keyId,
quantity: body.quantity,
});
await setBuyCooldownForWallet(keyId, walletAddress);
sendSuccess(res, result, 200);
}
2 changes: 2 additions & 0 deletions src/modules/creators/creators.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -59,6 +60,7 @@ creatorsRouter.post(
platformPauseGuard(),
requireStellarSignature(),
buyKeyRateLimit,
enforceBuyCooldown(),
validateBody(buySchema),
httpBuyCreatorKey
);
Expand Down
170 changes: 169 additions & 1 deletion src/modules/keys/key-cooldown.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<number> {
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<string | null> {
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).
Expand Down
Loading