From 641277f960a6b909a10d398d38fa1af2ccae2b9e Mon Sep 17 00:00:00 2001 From: David Dada Date: Sun, 30 Aug 2026 21:25:27 +0100 Subject: [PATCH 1/4] feat: add referral, rate, and sub-account limits --- .../migration.sql | 2 + prisma/schema.prisma | 3 + src/config/env.ts | 3 + src/controllers/referral-controller.ts | 25 +++++- src/middleware/rateLimiter.ts | 15 +++- src/middleware/subAccount.ts | 33 +++++++- src/referral/service.ts | 82 ++++++++++++++++++- src/routes/referrals.ts | 2 + src/routes/sub-accounts.ts | 6 +- 9 files changed, 165 insertions(+), 6 deletions(-) create mode 100644 prisma/migrations/20260830200000_referral_tier2_subaccount_limits/migration.sql diff --git a/prisma/migrations/20260830200000_referral_tier2_subaccount_limits/migration.sql b/prisma/migrations/20260830200000_referral_tier2_subaccount_limits/migration.sql new file mode 100644 index 0000000..ca4c964 --- /dev/null +++ b/prisma/migrations/20260830200000_referral_tier2_subaccount_limits/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "sub_accounts" ADD COLUMN "dailyLimit" DECIMAL(36,18), ADD COLUMN "transactionLimit" DECIMAL(36,18); +ALTER TABLE "referral_conversions" ADD COLUMN "tier2RewardTxId" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6ba4b8b..148f2ef 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -855,6 +855,8 @@ model SubAccount { status SubAccountStatus @default(ACTIVE) createdAt DateTime @default(now()) revokedAt DateTime? + dailyLimit Decimal? @db.Decimal(36, 18) + transactionLimit Decimal? @db.Decimal(36, 18) parent User @relation("ParentOf", fields: [parentUserId], references: [id], onDelete: Cascade) child User @relation("ChildOf", fields: [childUserId], references: [id], onDelete: Cascade) @@ -1064,6 +1066,7 @@ model ReferralConversion { status ReferralStatus @default(PENDING) ownerRewardTxId String? // REFERRAL_REWARD Transaction paid to the referrer referredRewardTxId String? // REFERRAL_REWARD Transaction paid to the referred user + tier2RewardTxId String? payoutError String? // last payout failure reason — kept for retriable visibility // #397 — fraud/abuse review trail. fraudReasons/flaggedAt are set when the // heuristic in evaluateReferralFraudRisk() flags a conversion instead of diff --git a/src/config/env.ts b/src/config/env.ts index a09c88e..a18ac19 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -575,6 +575,9 @@ export const config = { ), ownerReward: parseFloat(process.env.REFERRAL_OWNER_REWARD || '5'), referredReward: parseFloat(process.env.REFERRAL_REFERRED_REWARD || '5'), + tier2Enabled: + (process.env.REFERRAL_TIER2_ENABLED || 'false').toLowerCase() === 'true', + tier2Reward: parseFloat(process.env.REFERRAL_TIER2_REWARD || '1'), rewardAsset: process.env.REFERRAL_REWARD_ASSET || 'USDC', rewardContractMethod: process.env.REFERRAL_REWARD_CONTRACT_METHOD || 'transfer_reward', diff --git a/src/controllers/referral-controller.ts b/src/controllers/referral-controller.ts index abc9065..e792610 100644 --- a/src/controllers/referral-controller.ts +++ b/src/controllers/referral-controller.ts @@ -3,7 +3,30 @@ import { Request, Response } from 'express' import { logger } from '../utils/logger' import { sendError, sendUnauthorized } from '../utils/errors' -import { getOrCreateReferralCode, listReferrals } from '../referral/service' +import { + getOrCreateReferralCode, + listReferrals, + referralLeaderboard, +} from '../referral/service' + +export async function getReferralLeaderboard( + req: Request, + res: Response +): Promise { + try { + const page = Math.max(1, Number(req.query.page) || 1) + const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 20)) + const displayName = req.query.displayName === 'true' + res.json({ + page, + limit, + leaderboard: await referralLeaderboard(page, limit, displayName), + }) + } catch (error) { + logger.error('[Referral] Failed to load leaderboard:', error) + sendError(res, 500, 'Failed to retrieve leaderboard') + } +} /** * GET /api/referrals/code diff --git a/src/middleware/rateLimiter.ts b/src/middleware/rateLimiter.ts index 42c96c8..57b41e9 100644 --- a/src/middleware/rateLimiter.ts +++ b/src/middleware/rateLimiter.ts @@ -3,6 +3,8 @@ import rateLimit from 'express-rate-limit' import { config } from '../config/env' import { recordRateLimitHit } from '../utils/metrics' import { logger } from '../utils/logger' +import db from '../db' +import crypto from 'node:crypto' // ── Trusted-IP / service-token bypass ───────────────────────────────────── @@ -117,7 +119,18 @@ export function buildRateLimiter( const limiter = rateLimit({ windowMs: opts.windowMs, - max: opts.max, + max: async (req: Request) => { + const token = req.header('Authorization')?.replace(/^Bearer\s+/, '') + if (!token?.startsWith('nwk_')) return opts.max + const keyId = token.split('_')[1] + const tokenPrefix = + 'sha256:' + crypto.createHash('sha256').update(token).digest('hex') + const key = await (db as any).userApiKey.findFirst({ + where: { id: keyId, tokenPrefix, revokedAt: null }, + select: { rateLimitPerMin: true }, + }) + return key?.rateLimitPerMin ?? opts.max + }, standardHeaders: true, legacyHeaders: false, skip: opts.skip, diff --git a/src/middleware/subAccount.ts b/src/middleware/subAccount.ts index 096c8e2..2919b50 100644 --- a/src/middleware/subAccount.ts +++ b/src/middleware/subAccount.ts @@ -37,7 +37,8 @@ export function requireSubAccountPermission(permission: SubAccountPermission) { const result = await checkSubAccountPermission( req.auth.userId, targetUserId, - permission + permission, + permission === 'WITHDRAW' ? Number(req.body?.amount) : undefined ) if (!result.allowed) { @@ -86,7 +87,8 @@ export type SubAccountPermissionCheck = export async function checkSubAccountPermission( parentUserId: string, childUserId: string, - permission: SubAccountPermission + permission: SubAccountPermission, + amount?: number ): Promise { const subAccount = await db.subAccount.findUnique({ where: { @@ -102,5 +104,32 @@ export async function checkSubAccountPermission( return { allowed: false, reason: 'no_permission' } } + if (permission === 'WITHDRAW' && amount !== undefined) { + if ( + subAccount.transactionLimit != null && + amount > Number(subAccount.transactionLimit) + ) + return { allowed: false, reason: 'no_permission' } + if (subAccount.dailyLimit != null) { + const since = new Date() + since.setUTCHours(0, 0, 0, 0) + const spent = await db.transaction.aggregate({ + where: { + actingAsUserId: parentUserId, + userId: childUserId, + type: 'WITHDRAWAL', + createdAt: { gte: since }, + status: { not: 'FAILED' }, + }, + _sum: { amount: true }, + }) + if ( + Number(spent._sum.amount ?? 0) + amount > + Number(subAccount.dailyLimit) + ) + return { allowed: false, reason: 'no_permission' } + } + } + return { allowed: true } } diff --git a/src/referral/service.ts b/src/referral/service.ts index 315bf4a..5a62520 100644 --- a/src/referral/service.ts +++ b/src/referral/service.ts @@ -562,7 +562,7 @@ export async function payoutActivatedConversions(): Promise<{ }) const network = referredUser?.network ?? Network.MAINNET - let { ownerRewardTxId, referredRewardTxId } = conversion + let { ownerRewardTxId, referredRewardTxId, tier2RewardTxId } = conversion let hadError = false // Owner leg. @@ -605,6 +605,48 @@ export async function payoutActivatedConversions(): Promise<{ } } + if ( + !tier2RewardTxId && + config.referral.tier2Enabled && + config.referral.tier2Reward > 0 + ) { + const parentCode = await db.referralCode.findFirst({ + where: { ownerUserId: ownerUserId }, + select: { ownerUserId: true }, + }) + // A tier-2 relationship is represented by the owner's own active code + // being attributed to its parent; inactive/deleted codes earn nothing. + const parentConversion = + parentCode && + (await db.referralConversion.findFirst({ + where: { + referralCode: { ownerUserId: { not: ownerUserId } }, + referredUserId: ownerUserId, + status: { in: [ReferralStatus.ACTIVATED, ReferralStatus.REWARDED] }, + }, + include: { referralCode: true }, + })) + const tier2Owner = parentConversion?.referralCode.ownerUserId + if (tier2Owner) { + try { + tier2RewardTxId = await payOneReward( + tier2Owner, + config.referral.tier2Reward, + network, + conversion.id, + 'tier2' + ) + await db.referralConversion.update({ + where: { id: conversion.id }, + data: { tier2RewardTxId, payoutError: null }, + }) + } catch (err) { + hadError = true + await recordPayoutFailure(conversion.id, 'tier2', err) + } + } + } + if (hadError) continue // stays ACTIVATED — retried next sweep // Every owed leg is now paid. Advance to REWARDED (terminal). @@ -684,6 +726,44 @@ export async function listReferrals(ownerUserId: string) { } } +export async function referralLeaderboard( + page = 1, + limit = 20, + includeDisplayName = false +) { + const rows = await db.referralConversion.groupBy({ + by: ['referralCodeId'], + where: { status: ReferralStatus.REWARDED }, + _count: { id: true }, + orderBy: { _count: { id: 'desc' } }, + skip: (page - 1) * limit, + take: limit, + }) + const codes = await db.referralCode.findMany({ + where: { id: { in: rows.map((r) => r.referralCodeId) } }, + select: { id: true, ownerUserId: true }, + }) + const users = includeDisplayName + ? await db.user.findMany({ + where: { id: { in: codes.map((c) => c.ownerUserId) } }, + select: { id: true, displayName: true }, + }) + : [] + return rows.map((row) => { + const ownerId = codes.find((c) => c.id === row.referralCodeId)?.ownerUserId + return { + userId: ownerId, + activatedConversions: row._count.id, + ...(includeDisplayName + ? { + displayName: + users.find((u) => u.id === ownerId)?.displayName ?? null, + } + : {}), + } + }) +} + /** * List FLAGGED conversions awaiting manual review (#397), oldest first so a * reviewer works the backlog in order. Used by the admin review route. diff --git a/src/routes/referrals.ts b/src/routes/referrals.ts index de8f3d3..6680f05 100644 --- a/src/routes/referrals.ts +++ b/src/routes/referrals.ts @@ -15,6 +15,7 @@ import { referralUserParamsSchema } from '../validators/referral-validators' import { getMyReferralCode, getReferrals, + getReferralLeaderboard, } from '../controllers/referral-controller' const router = Router() @@ -22,6 +23,7 @@ const router = Router() // The caller's own code. Must precede the /:userId route so "code" is not // captured as a userId. router.get('/code', requireAuth, getMyReferralCode) +router.get('/leaderboard', getReferralLeaderboard) router.get( '/:userId', diff --git a/src/routes/sub-accounts.ts b/src/routes/sub-accounts.ts index a208a83..cf363fa 100644 --- a/src/routes/sub-accounts.ts +++ b/src/routes/sub-accounts.ts @@ -16,6 +16,8 @@ const createSubAccountSchema = z.object({ .array(z.enum(PERMISSION_VALUES as [string, ...string[]])) .min(1) .max(4), + dailyLimit: z.number().positive().optional(), + transactionLimit: z.number().positive().optional(), }) const updatePermissionsSchema = z.object({ @@ -31,7 +33,7 @@ router.post( requireAuth, validate({ body: createSubAccountSchema, errorMessage: 'Validation error' }), async (req: Request, res: Response) => { - const { childUserId, permissions } = req.body + const { childUserId, permissions, dailyLimit, transactionLimit } = req.body const parentUserId = req.auth!.userId // Prevent self-referencing @@ -101,6 +103,8 @@ router.post( parentUserId, childUserId, permissions: permissions as SubAccountPermission[], + dailyLimit: dailyLimit ?? null, + transactionLimit: transactionLimit ?? null, }, }) From f1e1b6e843695e3825621d7a96a7a1eb621d6eda Mon Sep 17 00:00:00 2001 From: David Dada Date: Sun, 30 Aug 2026 21:47:39 +0100 Subject: [PATCH 2/4] fix: add referral migration rollback and tier2 typing --- .../rollback.sql | 3 +++ src/referral/service.ts | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20260830200000_referral_tier2_subaccount_limits/rollback.sql diff --git a/prisma/migrations/20260830200000_referral_tier2_subaccount_limits/rollback.sql b/prisma/migrations/20260830200000_referral_tier2_subaccount_limits/rollback.sql new file mode 100644 index 0000000..a3cceb2 --- /dev/null +++ b/prisma/migrations/20260830200000_referral_tier2_subaccount_limits/rollback.sql @@ -0,0 +1,3 @@ +ALTER TABLE "referral_conversions" DROP COLUMN IF EXISTS "tier2RewardTxId"; +ALTER TABLE "sub_accounts" DROP COLUMN IF EXISTS "transactionLimit"; +ALTER TABLE "sub_accounts" DROP COLUMN IF EXISTS "dailyLimit"; diff --git a/src/referral/service.ts b/src/referral/service.ts index 5a62520..102e573 100644 --- a/src/referral/service.ts +++ b/src/referral/service.ts @@ -459,7 +459,7 @@ async function payOneReward( amount: number, network: Network, conversionId: string, - leg: 'owner' | 'referred' + leg: 'owner' | 'referred' | 'tier2' ): Promise { const address = await resolveRewardAddress(recipientUserId) if (!address) { @@ -667,7 +667,7 @@ export async function payoutActivatedConversions(): Promise<{ async function recordPayoutFailure( conversionId: string, - leg: 'owner' | 'referred', + leg: 'owner' | 'referred' | 'tier2', err: unknown ): Promise { const message = err instanceof Error ? err.message : String(err) From ed8bf4998bec15409a4b936355093f3b2364f497 Mon Sep 17 00:00:00 2001 From: David Dada Date: Sun, 30 Aug 2026 21:51:32 +0100 Subject: [PATCH 3/4] fix: allow tier2 referral outbox leg --- src/outbox/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/outbox/types.ts b/src/outbox/types.ts index a741e15..0aad8ae 100644 --- a/src/outbox/types.ts +++ b/src/outbox/types.ts @@ -54,7 +54,7 @@ export type OutboxPayload = amount: number assetSymbol: string conversionId: string - leg: 'owner' | 'referred' + leg: 'owner' | 'referred' | 'tier2' } export interface OutboxOpRecord { From 101eabf427ef467e163109e41e1d485a54e17086 Mon Sep 17 00:00:00 2001 From: David Dada Date: Mon, 31 Aug 2026 03:05:05 +0100 Subject: [PATCH 4/4] fix: omit unset sub-account limits --- src/routes/sub-accounts.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/sub-accounts.ts b/src/routes/sub-accounts.ts index cf363fa..3df200e 100644 --- a/src/routes/sub-accounts.ts +++ b/src/routes/sub-accounts.ts @@ -103,8 +103,8 @@ router.post( parentUserId, childUserId, permissions: permissions as SubAccountPermission[], - dailyLimit: dailyLimit ?? null, - transactionLimit: transactionLimit ?? null, + ...(dailyLimit !== undefined ? { dailyLimit } : {}), + ...(transactionLimit !== undefined ? { transactionLimit } : {}), }, })