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
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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";
3 changes: 3 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
25 changes: 24 additions & 1 deletion src/controllers/referral-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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
Expand Down
15 changes: 14 additions & 1 deletion src/middleware/rateLimiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────

Expand Down Expand Up @@ -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,
Expand Down
33 changes: 31 additions & 2 deletions src/middleware/subAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -86,7 +87,8 @@ export type SubAccountPermissionCheck =
export async function checkSubAccountPermission(
parentUserId: string,
childUserId: string,
permission: SubAccountPermission
permission: SubAccountPermission,
amount?: number
): Promise<SubAccountPermissionCheck> {
const subAccount = await db.subAccount.findUnique({
where: {
Expand All @@ -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 }
}
2 changes: 1 addition & 1 deletion src/outbox/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export type OutboxPayload =
amount: number
assetSymbol: string
conversionId: string
leg: 'owner' | 'referred'
leg: 'owner' | 'referred' | 'tier2'
}

export interface OutboxOpRecord {
Expand Down
86 changes: 83 additions & 3 deletions src/referral/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,7 @@ async function payOneReward(
amount: number,
network: Network,
conversionId: string,
leg: 'owner' | 'referred'
leg: 'owner' | 'referred' | 'tier2'
): Promise<string> {
const address = await resolveRewardAddress(recipientUserId)
if (!address) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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).
Expand All @@ -625,7 +667,7 @@ export async function payoutActivatedConversions(): Promise<{

async function recordPayoutFailure(
conversionId: string,
leg: 'owner' | 'referred',
leg: 'owner' | 'referred' | 'tier2',
err: unknown
): Promise<void> {
const message = err instanceof Error ? err.message : String(err)
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/routes/referrals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ import { referralUserParamsSchema } from '../validators/referral-validators'
import {
getMyReferralCode,
getReferrals,
getReferralLeaderboard,
} from '../controllers/referral-controller'

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',
Expand Down
6 changes: 5 additions & 1 deletion src/routes/sub-accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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
Expand Down Expand Up @@ -101,6 +103,8 @@ router.post(
parentUserId,
childUserId,
permissions: permissions as SubAccountPermission[],
...(dailyLimit !== undefined ? { dailyLimit } : {}),
...(transactionLimit !== undefined ? { transactionLimit } : {}),
},
})

Expand Down
Loading