From 91a1c332cc939c8be017bae702dc2e911847eb18 Mon Sep 17 00:00:00 2001 From: Stanley Owoh Date: Sat, 26 Sep 2026 08:52:58 +0100 Subject: [PATCH] Build referral tracking and reward distribution (#910) --- .env.example | 4 + docs/ENVIRONMENT_VARIABLES.md | 1 + .../migration.sql | 51 ++ prisma/schema/referral.prisma | 53 ++ src/config.schema.ts | 5 + src/modules/index.ts | 2 + .../indexer/indexer-pipeline.service.ts | 44 +- .../referral-first-trade.integration.test.ts | 146 +++++ src/modules/referrals/referrals.constants.ts | 25 + src/modules/referrals/referrals.controller.ts | 205 +++++++ .../referrals/referrals.integration.test.ts | 345 +++++++++++ src/modules/referrals/referrals.routes.ts | 50 ++ src/modules/referrals/referrals.schemas.ts | 89 +++ .../referrals/referrals.service.test.ts | 559 ++++++++++++++++++ src/modules/referrals/referrals.service.ts | 500 ++++++++++++++++ 15 files changed, 2069 insertions(+), 10 deletions(-) create mode 100644 prisma/schema/migrations/20260926000000_add_referral_registration/migration.sql create mode 100644 src/modules/indexer/referral-first-trade.integration.test.ts create mode 100644 src/modules/referrals/referrals.constants.ts create mode 100644 src/modules/referrals/referrals.controller.ts create mode 100644 src/modules/referrals/referrals.integration.test.ts create mode 100644 src/modules/referrals/referrals.routes.ts create mode 100644 src/modules/referrals/referrals.schemas.ts create mode 100644 src/modules/referrals/referrals.service.test.ts create mode 100644 src/modules/referrals/referrals.service.ts diff --git a/.env.example b/.env.example index e66a53e8..f2aa9f92 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,10 @@ LOCKUP_DURATION_SECONDS=0 # aggregated into a pool per cycle and claimed proportionally to stake weight. REVENUE_DISTRIBUTION_CYCLE_DAYS=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. +REFERRAL_REWARD_BPS=500 + # 2-of-3 admin multisig quorum for key deprecation (#882). # Comma-separated Stellar addresses of the three admin wallets. ADMIN_MULTISIG_WALLETS= diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index 10599bea..526efb6a 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -35,6 +35,7 @@ Complete reference for all server configuration environment variables. | Variable | Type | Required | Default | Description | | --------------------------------- | ------ | -------- | ------- | ------------------------------------------------------------------------------------------------- | | `REVENUE_DISTRIBUTION_CYCLE_DAYS` | number | No | `7` | Length of each protocol revenue distribution cycle in days (#883) | +| `REFERRAL_REWARD_BPS` | number | No | `500` | Share of a referred wallet's first trade paid to the referrer, in basis points (500 = 5%). Paid once per referee (#910). | | `ADMIN_MULTISIG_WALLETS` | string | No | _(unset)_ | Comma-separated Stellar addresses of the 2-of-3 admin quorum for key deprecation (#882). When unset, two distinct valid signatures are still required but no allowlist is enforced (development default). | --- diff --git a/prisma/schema/migrations/20260926000000_add_referral_registration/migration.sql b/prisma/schema/migrations/20260926000000_add_referral_registration/migration.sql new file mode 100644 index 00000000..c18321a3 --- /dev/null +++ b/prisma/schema/migrations/20260926000000_add_referral_registration/migration.sql @@ -0,0 +1,51 @@ +-- Referral tracking and reward distribution (#910): referral codes issued per +-- wallet, the referee -> referrer relationship, and the referred-wallet column +-- on the referral fee ledger so earnings can be broken down per referral. + +-- CreateTable +CREATE TABLE "ReferralCode" ( + "id" TEXT NOT NULL, + "walletAddress" TEXT NOT NULL, + "code" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ReferralCode_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Referral" ( + "id" TEXT NOT NULL, + "referrerAddress" TEXT NOT NULL, + "refereeAddress" TEXT NOT NULL, + "referralCode" TEXT NOT NULL, + "firstTradeAt" TIMESTAMP(3), + "firstTradeKeyId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Referral_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ReferralCode_walletAddress_key" ON "ReferralCode"("walletAddress"); + +-- CreateIndex +CREATE UNIQUE INDEX "ReferralCode_code_key" ON "ReferralCode"("code"); + +-- CreateIndex +CREATE INDEX "ReferralCode_walletAddress_idx" ON "ReferralCode"("walletAddress"); + +-- CreateIndex +CREATE UNIQUE INDEX "Referral_refereeAddress_key" ON "Referral"("refereeAddress"); + +-- CreateIndex +CREATE INDEX "Referral_referrerAddress_createdAt_idx" ON "Referral"("referrerAddress", "createdAt"); + +-- CreateIndex +CREATE INDEX "Referral_referrerAddress_idx" ON "Referral"("referrerAddress"); + +-- AlterTable +ALTER TABLE "ReferralEvent" ADD COLUMN "refereeAddress" TEXT; + +-- CreateIndex +CREATE INDEX "ReferralEvent_refereeAddress_idx" ON "ReferralEvent"("refereeAddress"); diff --git a/prisma/schema/referral.prisma b/prisma/schema/referral.prisma index 9fe6db8c..a04dc8f9 100644 --- a/prisma/schema/referral.prisma +++ b/prisma/schema/referral.prisma @@ -1,5 +1,53 @@ // prisma/schema/referral.prisma +/** + * Referral code issued once per wallet (#910). Referrers share this code so + * new wallets can register as their referees via + * `POST /referrals/register`. The code is generated on demand and is stable + * for the lifetime of the wallet. + */ +model ReferralCode { + id String @id @default(cuid()) + + // Wallet the code belongs to + walletAddress String @unique + + // Public code shared by the referrer + code String @unique + + createdAt DateTime @default(now()) + + @@index([walletAddress]) +} + +/// Links a referee wallet to the referrer that owns its referral code (#910). +/// A wallet can only ever be referred once (`refereeAddress` is unique), so a +/// second registration attempt is a 409 conflict. +model Referral { + id String @id @default(cuid()) + + // Referrer wallet that owns the referral code + referrerAddress String + + // Referee wallet that registered with the referrer's code + refereeAddress String @unique + + // Referral code used at registration time + referralCode String + + // Set once the referee completes their first trade; null until then + firstTradeAt DateTime? + + // Key (creator id) of the trade that triggered the first-trade reward + firstTradeKeyId String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([referrerAddress, createdAt]) + @@index([referrerAddress]) +} + /** * Referral fee earnings indexed from the on-chain `referral_fee_paid` event. * @@ -18,6 +66,10 @@ model ReferralEvent { // Optional denormalised creator id for joins creatorId String? + // Referred wallet whose trade generated the fee. Null for fees that were + // not attributed to a specific referee (e.g. raw on-chain events). + refereeAddress String? + // Fee amount in XLM amount Decimal @@ -29,4 +81,5 @@ model ReferralEvent { @@index([walletAddress, createdAt]) @@index([keyId]) + @@index([refereeAddress]) } diff --git a/src/config.schema.ts b/src/config.schema.ts index 638429ff..f0d56902 100644 --- a/src/config.schema.ts +++ b/src/config.schema.ts @@ -125,6 +125,11 @@ export const envSchema = z .positive() .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 diff --git a/src/modules/index.ts b/src/modules/index.ts index 17136645..1e779bc2 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -27,6 +27,7 @@ import protocolRouter from './protocol/protocol.routes'; import revenueRouter from './revenue/revenue.routes'; import stakerRouter from './revenue/staker-revenue.routes'; import portfolioRouter from './portfolio/portfolio.routes'; +import referralRouter from './referrals/referrals.routes'; import { BASE as CREATORS_BASE } from '../constants/creator.constants'; const router = Router(); @@ -71,5 +72,6 @@ router.use('/protocol', routeBodySizeLimit('default'), protocolRouter); router.use('/revenue', routeBodySizeLimit('default'), revenueRouter); router.use('/staker', routeBodySizeLimit('default'), stakerRouter); router.use('/portfolio', routeBodySizeLimit('default'), portfolioRouter); +router.use('/referrals', routeBodySizeLimit('default'), referralRouter); export default router; diff --git a/src/modules/indexer/indexer-pipeline.service.ts b/src/modules/indexer/indexer-pipeline.service.ts index 014768a5..4fecd6a6 100644 --- a/src/modules/indexer/indexer-pipeline.service.ts +++ b/src/modules/indexer/indexer-pipeline.service.ts @@ -13,6 +13,7 @@ import { dedupeChainEvents } from '../../utils/indexer-dedupe.utils'; import { logSellTransactionConfirmed } from '../../utils/sell-transaction-logger.utils'; import { persistCirculatingSupply } from './persist-circulating-supply.service'; import { invalidateVolumeLeaderboardCache } from '../creators/creator-leaderboard-volume.service'; +import { recordFirstTradeReferralReward } from '../referrals/referrals.service'; /** * Processes a batch of on-chain trade events (KEY_BOUGHT or KEY_SOLD). @@ -44,6 +45,39 @@ export async function processTradeEvents(events: IndexerChainEvent[]): Promise { + const transactionClient = { + activity: { findMany: jest.fn(), create: jest.fn() }, + creatorProfile: { findUnique: jest.fn(), update: jest.fn() }, + }; + return { + prisma: { + activity: { create: jest.fn() }, + keyOwnership: { + findFirst: jest.fn(), + findUnique: jest.fn(), + upsert: jest.fn(), + aggregate: jest.fn(), + }, + creatorPriceSnapshot: { findUnique: jest.fn(), create: jest.fn(), update: jest.fn() }, + creatorPriceHistory: { create: jest.fn() }, + creatorProfile: { findUnique: jest.fn() }, + indexedLedger: { upsert: jest.fn() }, + $transaction: jest.fn( + async (cb: (tx: typeof transactionClient) => Promise) => + cb(transactionClient) + ), + }, + }; +}); + +jest.mock('../../utils/logger.utils', () => ({ + logger: { + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('../../utils/redis.utils', () => ({ + getRedis: jest.fn(() => ({ del: jest.fn().mockResolvedValue(1) })), +})); + +jest.mock('../referrals/referrals.service', () => ({ + recordFirstTradeReferralReward: jest.fn().mockResolvedValue(false), +})); + +import { processTradeEvents } from './indexer-pipeline.service'; +import { prisma } from '../../utils/prisma.utils'; +import { logger } from '../../utils/logger.utils'; +import { recordFirstTradeReferralReward } from '../referrals/referrals.service'; +import { IndexerChainEvent } from '../../utils/indexer-event-processor.utils'; + +const recordReward = recordFirstTradeReferralReward as jest.Mock; +const mockLogger = logger as unknown as { warn: jest.Mock }; + +const BUYER = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + +function buyEvent(overrides: Partial = {}): IndexerChainEvent { + return { + txHash: '0xhash-buy', + eventIndex: 0, + eventType: 'KEY_BOUGHT', + ledger: 20000, + creatorId: 'creator-abc', + actor: BUYER, + amount: 2, + // 1.5 XLM per key, in stroops + price: 15_000_000n, + feePaid: 10n, + tradeAt: '2026-09-05T12:00:00.000Z', + ...overrides, + } as IndexerChainEvent; +} + +describe('referral reward wiring in processTradeEvents', () => { + beforeEach(() => { + jest.clearAllMocks(); + (prisma as any).keyOwnership.upsert.mockResolvedValue({ balance: 2 }); + (prisma as any).keyOwnership.findFirst.mockResolvedValue(null); + (prisma as any).keyOwnership.findUnique.mockResolvedValue(null); + (prisma as any).keyOwnership.aggregate.mockResolvedValue({ + _sum: { balance: 2 }, + }); + (prisma as any).indexedLedger.upsert.mockResolvedValue({}); + (prisma as any).$transaction.mockImplementation( + async (cb: any) => + cb({ + activity: { + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn(), + }, + creatorProfile: { + findUnique: jest.fn().mockResolvedValue(null), + update: jest.fn(), + }, + }) + ); + }); + + it('records the referral reward with the total XLM value of the buy', async () => { + await processTradeEvents([buyEvent()]); + + expect(recordReward).toHaveBeenCalledTimes(1); + expect(recordReward).toHaveBeenCalledWith({ + refereeAddress: BUYER, + keyId: 'creator-abc', + // 1.5 XLM * 2 keys + tradeValueXlm: 3, + txHash: '0xhash-buy', + eventIndex: 0, + tradeAt: new Date('2026-09-05T12:00:00.000Z'), + }); + }); + + it('records the referral reward check for sells as well as buys', async () => { + await processTradeEvents([ + buyEvent({ eventType: 'KEY_SOLD', txHash: '0xhash-sell' }), + ]); + + expect(recordReward).toHaveBeenCalledWith( + expect.objectContaining({ + refereeAddress: BUYER, + keyId: 'creator-abc', + tradeValueXlm: 3, + txHash: '0xhash-sell', + }) + ); + }); + + it('aborts the event before trade writes when referral bookkeeping fails', async () => { + recordReward.mockRejectedValueOnce(new Error('referral table locked')); + + await expect(processTradeEvents([buyEvent()])).rejects.toThrow( + 'referral table locked' + ); + + expect((prisma as any).activity.create).not.toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ eventId: '0xhash-buy:0' }), + 'Failed to record referral first trade reward' + ); + }); +}); diff --git a/src/modules/referrals/referrals.constants.ts b/src/modules/referrals/referrals.constants.ts new file mode 100644 index 00000000..491b5d70 --- /dev/null +++ b/src/modules/referrals/referrals.constants.ts @@ -0,0 +1,25 @@ +// src/modules/referrals/referrals.constants.ts +// Tunables for the referral programme (#910). + +/** Number of characters in a generated referral code. */ +export const REFERRAL_CODE_LENGTH = 10; + +/** + * Alphabet for generated referral codes. Crockford-style base32 without the + * visually ambiguous characters (0, 1, I, O) so codes stay readable when + * shared verbally. + */ +export const REFERRAL_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + +/** + * Number of attempts before giving up on generating a unique referral code. + * Collisions are astronomically unlikely at the lengths above; the retry loop + * exists so a collision can never fail a request. + */ +export const REFERRAL_CODE_MAX_ATTEMPTS = 5; + +/** Default number of referred wallets returned by GET /referrals/referred. */ +export const DEFAULT_REFERRED_PAGE_SIZE = 20; + +/** Decimal places kept for XLM amounts (matches the Stellar precision). */ +export const XLM_DECIMALS = 7; diff --git a/src/modules/referrals/referrals.controller.ts b/src/modules/referrals/referrals.controller.ts new file mode 100644 index 00000000..1b51def0 --- /dev/null +++ b/src/modules/referrals/referrals.controller.ts @@ -0,0 +1,205 @@ +// src/modules/referrals/referrals.controller.ts +// Handlers for the referral programme endpoints (#910): +// POST /api/v1/referrals/register +// GET /api/v1/referrals/earnings +// GET /api/v1/referrals/referred +// +// All three require a JWT; the wallet is always taken from the token, never +// from the request body, so a caller can only act on its own referral data. + +import { Response } from 'express'; +import { + ErrorCode, + sendError, + sendSuccess, + sendValidationError, + zodIssuesToDetails, +} from '../../utils/api-response.utils'; +import { attachTimestampHeader } from '../../utils/timestamp-headers.utils'; +import { CursorChecksumError } from '../../utils/cursor.utils'; +import { logger } from '../../utils/logger.utils'; +import { AuthenticatedRequest } from '../../middlewares/jwt-auth.middleware'; +import { ReferredWalletsQuerySchema, RegisterReferralSchema } from './referrals.schemas'; +import { + AlreadyReferredError, + getReferralEarnings, + listReferredWallets, + ReferralCodeNotFoundError, + registerReferral, + SelfReferralError, +} from './referrals.service'; + +/** + * POST /api/v1/referrals/register + * + * Links the authenticated wallet (the referee) to the wallet that owns + * `referralCode` (the referrer). A wallet can only be referred once, so a + * second registration returns 409. + */ +export async function httpRegisterReferral( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const parsed = RegisterReferralSchema.safeParse(req.body); + if (!parsed.success) { + sendValidationError( + res, + 'Invalid request body', + zodIssuesToDetails(parsed.error.issues) + ); + return; + } + + const registered = await registerReferral( + req.user!.wallet, + parsed.data.referralCode + ); + + attachTimestampHeader(res); + sendSuccess( + res, + registered, + 201, + 'Referral registered successfully' + ); + } catch (error) { + if (error instanceof ReferralCodeNotFoundError) { + sendError(res, 404, ErrorCode.NOT_FOUND, error.message); + return; + } + if (error instanceof SelfReferralError) { + sendError(res, 400, ErrorCode.BAD_REQUEST, error.message); + return; + } + if (error instanceof AlreadyReferredError) { + sendError(res, 409, ErrorCode.CONFLICT, error.message); + return; + } + logger.error( + { + type: 'referral_register_failed', + ...(req.requestId ? { requestId: req.requestId } : {}), + error, + }, + 'Failed to register referral' + ); + sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'Failed to register referral'); + } +} + +/** + * GET /api/v1/referrals/earnings + * + * Total XLM earned from referrals, the wallet's own referral code, and a + * per-referral breakdown of what each referred wallet has paid out. + */ +export async function httpGetReferralEarnings( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const parsed = ReferredWalletsQuerySchema.safeParse(req.query); + if (!parsed.success) { + sendValidationError( + res, + 'Invalid query parameters', + zodIssuesToDetails(parsed.error.issues) + ); + return; + } + + const earnings = await getReferralEarnings(req.user!.wallet, parsed.data); + + attachTimestampHeader(res); + sendSuccess( + res, + earnings, + 200, + 'Referral earnings retrieved successfully' + ); + } catch (error) { + if (error instanceof CursorChecksumError) { + sendValidationError(res, 'Invalid cursor', [ + { field: 'cursor', message: error.message }, + ]); + return; + } + logger.error( + { + type: 'referral_earnings_failed', + ...(req.requestId ? { requestId: req.requestId } : {}), + error, + }, + 'Failed to retrieve referral earnings' + ); + sendError( + res, + 500, + ErrorCode.INTERNAL_ERROR, + 'Failed to retrieve referral earnings' + ); + } +} + +/** + * GET /api/v1/referrals/referred + * + * Cursor-paginated list of the wallets referred by the authenticated wallet, + * each with its join date and whether it has completed its first trade. + */ +export async function httpGetReferredWallets( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const parsed = ReferredWalletsQuerySchema.safeParse(req.query); + if (!parsed.success) { + sendValidationError( + res, + 'Invalid query parameters', + zodIssuesToDetails(parsed.error.issues) + ); + return; + } + + const page = await listReferredWallets(req.user!.wallet, parsed.data); + + attachTimestampHeader(res); + sendSuccess( + res, + { + referred: page.items, + totalCount: page.items.length, + pagination: { + limit: parsed.data.limit, + nextCursor: page.next_cursor, + hasMore: page.has_more, + }, + }, + 200, + 'Referred wallets retrieved successfully' + ); + } catch (error) { + if (error instanceof CursorChecksumError) { + sendValidationError(res, 'Invalid cursor', [ + { field: 'cursor', message: error.message }, + ]); + return; + } + logger.error( + { + type: 'referral_referred_list_failed', + ...(req.requestId ? { requestId: req.requestId } : {}), + error, + }, + 'Failed to retrieve referred wallets' + ); + sendError( + res, + 500, + ErrorCode.INTERNAL_ERROR, + 'Failed to retrieve referred wallets' + ); + } +} diff --git a/src/modules/referrals/referrals.integration.test.ts b/src/modules/referrals/referrals.integration.test.ts new file mode 100644 index 00000000..b0dc0554 --- /dev/null +++ b/src/modules/referrals/referrals.integration.test.ts @@ -0,0 +1,345 @@ +// Route-level tests for the referral programme endpoints (#910). +// +// Exercises the mounted /referrals router (auth guard, validation, status +// codes and response envelopes) with a mocked Prisma layer so the behaviour +// can be asserted without a live database. + +jest.mock('tspec', () => ({ + TspecDocsMiddleware: jest.fn().mockResolvedValue([]), +})); + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + referralCode: { findUnique: jest.fn(), create: jest.fn() }, + referral: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + count: jest.fn(), + updateMany: jest.fn(), + }, + referralEvent: { + create: jest.fn(), + aggregate: jest.fn(), + groupBy: jest.fn(), + }, + }, +})); + +jest.mock('../../utils/logger.utils', () => ({ + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + isLevelEnabled: jest.fn().mockReturnValue(false), + }, +})); + +import supertest from 'supertest'; +import app from '../../app'; +import { prisma } from '../../utils/prisma.utils'; +import { signWalletAccessToken } from '../../utils/jwt.utils'; +import { REFERRAL_CODE_LENGTH } from './referrals.constants'; + +const codeFindUnique = prisma.referralCode.findUnique as jest.Mock; +const codeCreate = prisma.referralCode.create as jest.Mock; +const referralFindUnique = prisma.referral.findUnique as jest.Mock; +const referralFindMany = prisma.referral.findMany as jest.Mock; +const referralCreate = prisma.referral.create as jest.Mock; +const referralCount = prisma.referral.count as jest.Mock; +const eventAggregate = prisma.referralEvent.aggregate as jest.Mock; +const eventGroupBy = prisma.referralEvent.groupBy as jest.Mock; + +const REFERRER = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const REFEREE = 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; +const CODE = 'ABCD2345EF'; + +function auth(wallet: string) { + return { Authorization: `Bearer ${signWalletAccessToken(wallet)}` }; +} + +function referralRow(overrides: Record = {}) { + return { + id: 'ref-1', + referrerAddress: REFERRER, + refereeAddress: REFEREE, + referralCode: CODE, + firstTradeAt: null, + firstTradeKeyId: null, + createdAt: new Date('2026-09-01T10:00:00.000Z'), + updatedAt: new Date('2026-09-01T10:00:00.000Z'), + ...overrides, + }; +} + +describe('referral routes', () => { + beforeEach(() => { + jest.clearAllMocks(); + eventGroupBy.mockResolvedValue([]); + }); + + describe('POST /api/v1/referrals/register', () => { + it('requires authentication', async () => { + const res = await supertest(app) + .post('/api/v1/referrals/register') + .send({ referralCode: CODE }); + + expect(res.status).toBe(401); + expect(referralCreate).not.toHaveBeenCalled(); + }); + + it('links the authenticated wallet to the owner of the referral code', async () => { + codeFindUnique.mockResolvedValue({ walletAddress: REFERRER }); + referralFindUnique.mockResolvedValue(null); + referralCreate.mockResolvedValue(referralRow({ id: 'ref-42' })); + + const res = await supertest(app) + .post('/api/v1/referrals/register') + .set(auth(REFEREE)) + .send({ referralCode: CODE }); + + expect(res.status).toBe(201); + expect(res.body).toEqual( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + referralId: 'ref-42', + referrerAddress: REFERRER, + refereeAddress: REFEREE, + status: 'PENDING', + }), + }) + ); + expect(referralCreate).toHaveBeenCalledWith({ + data: { + referrerAddress: REFERRER, + refereeAddress: REFEREE, + referralCode: CODE, + }, + }); + }); + + it('returns 409 when the wallet has already been referred', async () => { + codeFindUnique.mockResolvedValue({ walletAddress: REFERRER }); + referralFindUnique.mockResolvedValue({ + referrerAddress: 'GDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD', + }); + + const res = await supertest(app) + .post('/api/v1/referrals/register') + .set(auth(REFEREE)) + .send({ referralCode: CODE }); + + expect(res.status).toBe(409); + expect(res.body).toEqual( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ code: 'CONFLICT' }), + }) + ); + expect(referralCreate).not.toHaveBeenCalled(); + }); + + it('returns 404 for an unknown referral code', async () => { + codeFindUnique.mockResolvedValue(null); + + const res = await supertest(app) + .post('/api/v1/referrals/register') + .set(auth(REFEREE)) + .send({ referralCode: 'NOPE1234XX' }); + + expect(res.status).toBe(404); + expect(res.body).toEqual( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ code: 'NOT_FOUND' }), + }) + ); + }); + + it('returns 400 when a wallet registers with its own code', async () => { + codeFindUnique.mockResolvedValue({ walletAddress: REFEREE }); + + const res = await supertest(app) + .post('/api/v1/referrals/register') + .set(auth(REFEREE)) + .send({ referralCode: CODE }); + + expect(res.status).toBe(400); + expect(res.body).toEqual( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ code: 'BAD_REQUEST' }), + }) + ); + }); + + it('returns 400 when the referral code is missing', async () => { + const res = await supertest(app) + .post('/api/v1/referrals/register') + .set(auth(REFEREE)) + .send({}); + + expect(res.status).toBe(400); + expect(res.body.error.details[0].field).toBe('referralCode'); + expect(referralCreate).not.toHaveBeenCalled(); + }); + }); + + describe('GET /api/v1/referrals/earnings', () => { + beforeEach(() => { + codeFindUnique.mockResolvedValue({ code: CODE }); + referralCount.mockResolvedValue(0); + }); + + it('requires authentication', async () => { + const res = await supertest(app).get('/api/v1/referrals/earnings'); + expect(res.status).toBe(401); + }); + + it('returns the total earned and a per-referral breakdown', async () => { + const firstTradeAt = new Date('2026-09-05T12:00:00.000Z'); + eventAggregate.mockResolvedValue({ _sum: { amount: '7.25' } }); + referralCount.mockImplementation(async ({ where }: any) => + where.firstTradeAt ? 1 : 2 + ); + referralFindMany.mockResolvedValue([ + referralRow({ id: 'ref-1', firstTradeAt }), + referralRow({ id: 'ref-2', refereeAddress: 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC' }), + ]); + eventGroupBy.mockResolvedValue([ + { refereeAddress: REFEREE, _sum: { amount: '7.25' } }, + ]); + + const res = await supertest(app) + .get('/api/v1/referrals/earnings') + .set(auth(REFERRER)); + + expect(res.status).toBe(200); + expect(res.body.data).toEqual( + expect.objectContaining({ + referralCode: CODE, + totalEarned: 7.25, + rewardedReferralCount: 1, + referredCount: 2, + }) + ); + expect(res.body.data.breakdown).toHaveLength(2); + expect(res.body.data.breakdown[0]).toEqual( + expect.objectContaining({ + refereeAddress: REFEREE, + firstTradeAt: '2026-09-05T12:00:00.000Z', + status: 'ACTIVE', + earnedXlm: 7.25, + }) + ); + expect(res.body.data.breakdown[1]).toEqual( + expect.objectContaining({ + status: 'PENDING', + firstTradeAt: null, + earnedXlm: 0, + }) + ); + }); + + it('returns zero totals for a wallet that has referred nobody', async () => { + eventAggregate.mockResolvedValue({ _sum: { amount: null } }); + referralFindMany.mockResolvedValue([]); + + const res = await supertest(app) + .get('/api/v1/referrals/earnings') + .set(auth(REFERRER)); + + expect(res.status).toBe(200); + expect(res.body.data).toEqual( + expect.objectContaining({ + totalEarned: 0, + referredCount: 0, + rewardedReferralCount: 0, + breakdown: [], + }) + ); + }); + + it('issues a referral code on first read', async () => { + codeFindUnique.mockResolvedValueOnce(null).mockResolvedValueOnce(null); + codeCreate.mockImplementation(async ({ data }: any) => ({ + code: data.code, + })); + eventAggregate.mockResolvedValue({ _sum: { amount: '0' } }); + referralFindMany.mockResolvedValue([]); + + const res = await supertest(app) + .get('/api/v1/referrals/earnings') + .set(auth(REFERRER)); + + expect(res.status).toBe(200); + expect(res.body.data.referralCode).toHaveLength(REFERRAL_CODE_LENGTH); + }); + + it('rejects an out-of-range limit', async () => { + const res = await supertest(app) + .get('/api/v1/referrals/earnings?limit=0') + .set(auth(REFERRER)); + + expect(res.status).toBe(400); + expect(res.body.error.details[0].field).toBe('limit'); + }); + }); + + describe('GET /api/v1/referrals/referred', () => { + it('requires authentication', async () => { + const res = await supertest(app).get('/api/v1/referrals/referred'); + expect(res.status).toBe(401); + }); + + it('lists referred wallets with their join date and first-trade status', async () => { + referralFindMany.mockResolvedValue([ + referralRow({ id: 'ref-1', firstTradeAt: new Date('2026-09-05T12:00:00.000Z') }), + referralRow({ id: 'ref-2', refereeAddress: 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC' }), + ]); + + const res = await supertest(app) + .get('/api/v1/referrals/referred') + .set(auth(REFERRER)); + + expect(res.status).toBe(200); + expect(res.body.data.referred).toEqual([ + { + refereeAddress: REFEREE, + joinedAt: '2026-09-01T10:00:00.000Z', + firstTradeAt: '2026-09-05T12:00:00.000Z', + hasCompletedFirstTrade: true, + status: 'ACTIVE', + earnedXlm: 0, + }, + { + refereeAddress: 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC', + joinedAt: '2026-09-01T10:00:00.000Z', + firstTradeAt: null, + hasCompletedFirstTrade: false, + status: 'PENDING', + earnedXlm: 0, + }, + ]); + expect(res.body.data.pagination).toEqual({ + limit: 20, + nextCursor: null, + hasMore: false, + }); + }); + }); + + describe('method handling', () => { + it('returns 405 with an Allow header for unsupported methods', async () => { + const res = await supertest(app) + .post('/api/v1/referrals/earnings') + .set(auth(REFERRER)) + .send({}); + + expect(res.status).toBe(405); + expect(res.headers.allow).toBe('GET'); + }); + }); +}); diff --git a/src/modules/referrals/referrals.routes.ts b/src/modules/referrals/referrals.routes.ts new file mode 100644 index 00000000..9a151816 --- /dev/null +++ b/src/modules/referrals/referrals.routes.ts @@ -0,0 +1,50 @@ +// src/modules/referrals/referrals.routes.ts +// Referral tracking and reward distribution routes (#910). All routes require +// a JWT; the wallet is taken from the token so callers can only read or write +// their own referral data. + +import { Router } from 'express'; +import { requireJwtAuth } from '../../middlewares/jwt-auth.middleware'; +import { + httpGetReferralEarnings, + httpGetReferredWallets, + httpRegisterReferral, +} from './referrals.controller'; + +const referralRouter = Router(); + +/** + * POST /api/v1/referrals/register + * + * Registers the authenticated wallet as a referee of the wallet that owns the + * supplied referral code. Returns 409 when the wallet was already referred. + */ +referralRouter.post('/register', requireJwtAuth, httpRegisterReferral); + +/** + * GET /api/v1/referrals/earnings + * + * Total referral earnings for the authenticated wallet plus a per-referral + * breakdown, and the wallet's own referral code to share. + */ +referralRouter.get('/earnings', requireJwtAuth, httpGetReferralEarnings); + +/** + * GET /api/v1/referrals/referred + * + * Referred wallets with their join date and first-trade status. + */ +referralRouter.get('/referred', requireJwtAuth, httpGetReferredWallets); + +// 405 handlers so unsupported methods get a proper Allow header. +referralRouter.all('/register', (_req, res) => { + res.set('Allow', 'POST').sendStatus(405); +}); +referralRouter.all('/earnings', (_req, res) => { + res.set('Allow', 'GET').sendStatus(405); +}); +referralRouter.all('/referred', (_req, res) => { + res.set('Allow', 'GET').sendStatus(405); +}); + +export default referralRouter; diff --git a/src/modules/referrals/referrals.schemas.ts b/src/modules/referrals/referrals.schemas.ts new file mode 100644 index 00000000..30799b9a --- /dev/null +++ b/src/modules/referrals/referrals.schemas.ts @@ -0,0 +1,89 @@ +// src/modules/referrals/referrals.schemas.ts +// Request/response contracts for the referral programme endpoints (#910): +// POST /referrals/register +// GET /referrals/earnings +// GET /referrals/referred + +import { z } from 'zod'; +import { safeIntParam } from '../../utils/query.utils'; +import { MIN_PAGE_SIZE, MAX_PAGE_SIZE } from '../../constants/pagination.constants'; +import { DEFAULT_REFERRED_PAGE_SIZE } from './referrals.constants'; + +/** POST /referrals/register body. */ +export const RegisterReferralSchema = z + .object({ + /** + * Referral code shared by the referrer. Issued per wallet and + * retrieved via GET /referrals/earnings. + */ + referralCode: z + .string() + .trim() + .min(1, 'referralCode is required') + .max(64, 'referralCode must be at most 64 characters') + .transform((code) => code.toUpperCase()), + }) + .strict(); + +export type RegisterReferralInput = z.infer; + +/** GET /referrals/referred query. */ +export const ReferredWalletsQuerySchema = z + .object({ + limit: safeIntParam({ + defaultValue: DEFAULT_REFERRED_PAGE_SIZE, + min: MIN_PAGE_SIZE, + max: MAX_PAGE_SIZE, + label: 'Limit', + }), + cursor: z.string().optional(), + }) + .strict(); + +export type ReferredWalletsQuery = z.infer; + +/** Opaque cursor payload for the referred wallets list. */ +export interface ReferredCursorPayload { + /** ISO timestamp the list is ordered by (referral join time). */ + joinedAt: string; + /** Row id of the last item on the previous page (tiebreaker). */ + id: string; +} + +/** + * First-trade status of a referred wallet. `PENDING` means the wallet has + * joined but has not traded yet, so no reward has been paid out. + */ +export const ReferralStatusSchema = z.enum(['PENDING', 'ACTIVE']); +export type ReferralStatus = z.infer; + +export const ReferredWalletSchema = z.object({ + refereeAddress: z.string(), + joinedAt: z.string(), + firstTradeAt: z.string().nullable(), + hasCompletedFirstTrade: z.boolean(), + status: ReferralStatusSchema, + earnedXlm: z.number(), +}); +export type ReferredWallet = z.infer; + +export const ReferralEarningsSchema = z.object({ + referralCode: z.string(), + totalEarned: z.number(), + /** Number of referred wallets that have paid out a reward so far. */ + rewardedReferralCount: z.number(), + /** Number of wallets currently registered as referred by this wallet. */ + referredCount: z.number(), +}); +export type ReferralEarnings = z.infer; + +export const ReferralEarningsBreakdownItemSchema = z.object({ + refereeAddress: z.string(), + joinedAt: z.string(), + firstTradeAt: z.string().nullable(), + status: ReferralStatusSchema, + earnedXlm: z.number(), +}); +export type ReferralEarningsBreakdownItem = z.infer< + typeof ReferralEarningsBreakdownItemSchema +>; diff --git a/src/modules/referrals/referrals.service.test.ts b/src/modules/referrals/referrals.service.test.ts new file mode 100644 index 00000000..efc84ffe --- /dev/null +++ b/src/modules/referrals/referrals.service.test.ts @@ -0,0 +1,559 @@ +// Unit tests: referral tracking and reward distribution (#910) +// +// Covers the acceptance criteria: +// - referral registration stores the relationship correctly +// - earnings endpoint returns accurate totals and a per-referral breakdown +// - referred wallets list shows join date and first-trade status +// - referral fee is recorded only on the referred wallet's first trade +// - duplicate referral registration for the same wallet is rejected + +jest.mock('../../utils/prisma.utils', () => { + const referral = { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + count: jest.fn(), + updateMany: jest.fn(), + }; + const referralEvent = { + create: jest.fn(), + aggregate: jest.fn(), + groupBy: jest.fn(), + }; + const prisma = { + referralCode: { findUnique: jest.fn(), create: jest.fn() }, + referral, + referralEvent, + $transaction: jest.fn((callback: (tx: unknown) => unknown) => + callback({ referral, referralEvent }) + ), + }; + + return { prisma }; +}); + +import { prisma } from '../../utils/prisma.utils'; +import { envConfig } from '../../config'; +import { encodeCursor } from '../../utils/cursor.utils'; +import { + AlreadyReferredError, + generateReferralCode, + getOrCreateReferralCode, + getReferralEarnings, + listReferredWallets, + recordFirstTradeReferralReward, + ReferralCodeNotFoundError, + registerReferral, + SelfReferralError, +} from './referrals.service'; +import { REFERRAL_CODE_ALPHABET, REFERRAL_CODE_LENGTH } from './referrals.constants'; + +const codeFindUnique = prisma.referralCode.findUnique as jest.Mock; +const codeCreate = prisma.referralCode.create as jest.Mock; +const referralFindUnique = prisma.referral.findUnique as jest.Mock; +const referralFindMany = prisma.referral.findMany as jest.Mock; +const referralCreate = prisma.referral.create as jest.Mock; +const referralCount = prisma.referral.count as jest.Mock; +const referralUpdateMany = prisma.referral.updateMany as jest.Mock; +const eventCreate = prisma.referralEvent.create as jest.Mock; +const eventAggregate = prisma.referralEvent.aggregate as jest.Mock; +const eventGroupBy = prisma.referralEvent.groupBy as jest.Mock; + +const REFERRER = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const REFEREE = 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; +const REFEREE_2 = 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC'; +const CODE = 'ABCD2345EF'; + +function referralRow(overrides: Record = {}) { + return { + id: 'ref-1', + referrerAddress: REFERRER, + refereeAddress: REFEREE, + referralCode: CODE, + firstTradeAt: null, + firstTradeKeyId: null, + createdAt: new Date('2026-09-01T10:00:00.000Z'), + updatedAt: new Date('2026-09-01T10:00:00.000Z'), + ...overrides, + }; +} + +/** Prisma P2002 (unique constraint violation) shape. */ +function uniqueViolation(target: string) { + return Object.assign(new Error(`Unique constraint failed on ${target}`), { + name: 'PrismaClientKnownRequestError', + code: 'P2002', + meta: { target: [target] }, + }); +} + +describe('referral code generation', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('generates codes of the configured length from the unambiguous alphabet', () => { + for (let i = 0; i < 50; i += 1) { + const code = generateReferralCode(); + expect(code).toHaveLength(REFERRAL_CODE_LENGTH); + for (const char of code) { + expect(REFERRAL_CODE_ALPHABET).toContain(char); + } + // Ambiguous characters must never appear. + expect(code).not.toMatch(/[01IO]/); + } + }); + + it('generates different codes for different wallets', () => { + const codes = new Set( + Array.from({ length: 100 }, () => generateReferralCode()) + ); + expect(codes.size).toBeGreaterThan(90); + }); + + describe('getOrCreateReferralCode', () => { + it('stores a generated code for a wallet that has none', async () => { + codeFindUnique.mockResolvedValue(null); + codeCreate.mockImplementation(async ({ data }: any) => ({ + code: data.code, + })); + + const code = await getOrCreateReferralCode(REFERRER); + + expect(code).toHaveLength(REFERRAL_CODE_LENGTH); + expect(codeCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: { walletAddress: REFERRER, code }, + }) + ); + }); + + it('returns the existing code without writing a new one', async () => { + codeFindUnique.mockResolvedValue({ code: CODE }); + + await expect(getOrCreateReferralCode(REFERRER)).resolves.toBe(CODE); + expect(codeCreate).not.toHaveBeenCalled(); + }); + + it('re-reads the code when a concurrent request created it first', async () => { + codeFindUnique + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ code: CODE }); + codeCreate.mockRejectedValue(uniqueViolation('code')); + + await expect(getOrCreateReferralCode(REFERRER)).resolves.toBe(CODE); + }); + + it('gives up after exhausting the collision retry budget', async () => { + codeFindUnique.mockResolvedValue(null); + codeCreate.mockRejectedValue(uniqueViolation('code')); + + await expect(getOrCreateReferralCode(REFERRER)).rejects.toThrow( + /Unable to generate a unique referral code/ + ); + expect(codeCreate).toHaveBeenCalledTimes(5); + }); + }); +}); + +describe('registerReferral', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('stores the relationship between the referee and the code owner', async () => { + codeFindUnique.mockResolvedValue({ walletAddress: REFERRER }); + referralFindUnique.mockResolvedValue(null); + referralCreate.mockResolvedValue(referralRow({ id: 'ref-42' })); + + const result = await registerReferral(REFEREE, CODE); + + expect(referralCreate).toHaveBeenCalledWith({ + data: { + referrerAddress: REFERRER, + refereeAddress: REFEREE, + referralCode: CODE, + }, + }); + expect(result).toEqual({ + referralId: 'ref-42', + referrerAddress: REFERRER, + refereeAddress: REFEREE, + referralCode: CODE, + joinedAt: '2026-09-01T10:00:00.000Z', + status: 'PENDING', + }); + }); + + it('normalises the submitted code so it matches the stored casing', async () => { + codeFindUnique.mockResolvedValue({ walletAddress: REFERRER }); + referralFindUnique.mockResolvedValue(null); + referralCreate.mockResolvedValue(referralRow()); + + await registerReferral(REFEREE, ` ${CODE.toLowerCase()} `); + + expect(codeFindUnique).toHaveBeenCalledWith({ + where: { code: CODE }, + select: { walletAddress: true }, + }); + }); + + it('rejects a duplicate registration for the same wallet', async () => { + codeFindUnique.mockResolvedValue({ walletAddress: REFERRER }); + referralFindUnique.mockResolvedValue({ + referrerAddress: 'GDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD', + }); + + await expect(registerReferral(REFEREE, CODE)).rejects.toBeInstanceOf( + AlreadyReferredError + ); + expect(referralCreate).not.toHaveBeenCalled(); + }); + + it('rejects a duplicate registration that races past the pre-check', async () => { + codeFindUnique.mockResolvedValue({ walletAddress: REFERRER }); + referralFindUnique.mockResolvedValue(null); + referralCreate.mockRejectedValue(uniqueViolation('refereeAddress')); + + await expect(registerReferral(REFEREE, CODE)).rejects.toBeInstanceOf( + AlreadyReferredError + ); + }); + + it('rejects an unknown referral code', async () => { + codeFindUnique.mockResolvedValue(null); + + await expect(registerReferral(REFEREE, 'NOPE1234XX')).rejects.toBeInstanceOf( + ReferralCodeNotFoundError + ); + expect(referralCreate).not.toHaveBeenCalled(); + }); + + it('rejects a wallet registering with its own code', async () => { + codeFindUnique.mockResolvedValue({ walletAddress: REFEREE }); + + await expect(registerReferral(REFEREE, CODE)).rejects.toBeInstanceOf( + SelfReferralError + ); + expect(referralCreate).not.toHaveBeenCalled(); + }); +}); + +describe('getReferralEarnings', () => { + beforeEach(() => { + jest.clearAllMocks(); + codeFindUnique.mockResolvedValue({ code: CODE }); + eventGroupBy.mockResolvedValue([]); + }); + + function mockCounts(referredCount: number, rewardedCount: number) { + referralCount.mockImplementation(async ({ where }: any) => + where.firstTradeAt ? rewardedCount : referredCount + ); + } + + it('returns zeros and an empty breakdown for a wallet with no referrals', async () => { + eventAggregate.mockResolvedValue({ _sum: { amount: null } }); + referralFindMany.mockResolvedValue([]); + mockCounts(0, 0); + + const result = await getReferralEarnings(REFERRER, { limit: 20 }); + + expect(result).toEqual({ + referralCode: CODE, + totalEarned: 0, + rewardedReferralCount: 0, + referredCount: 0, + breakdown: [], + pagination: { limit: 20, nextCursor: null, hasMore: false }, + }); + }); + + it('returns the total earned and a per-referral breakdown', async () => { + const firstTradeAt = new Date('2026-09-05T12:00:00.000Z'); + eventAggregate.mockResolvedValue({ _sum: { amount: '12.5' } }); + referralFindMany.mockResolvedValue([ + referralRow({ + id: 'ref-1', + refereeAddress: REFEREE, + firstTradeAt, + firstTradeKeyId: 'key-1', + }), + referralRow({ + id: 'ref-2', + refereeAddress: REFEREE_2, + firstTradeAt: null, + }), + ]); + eventGroupBy.mockResolvedValue([ + { refereeAddress: REFEREE, _sum: { amount: '10' } }, + { refereeAddress: REFEREE_2, _sum: { amount: null } }, + ]); + mockCounts(2, 1); + + const result = await getReferralEarnings(REFERRER, { limit: 20 }); + + expect(result.totalEarned).toBe(12.5); + expect(result.referredCount).toBe(2); + expect(result.rewardedReferralCount).toBe(1); + expect(result.breakdown).toEqual([ + { + refereeAddress: REFEREE, + joinedAt: '2026-09-01T10:00:00.000Z', + firstTradeAt: '2026-09-05T12:00:00.000Z', + status: 'ACTIVE', + earnedXlm: 10, + }, + { + refereeAddress: REFEREE_2, + joinedAt: '2026-09-01T10:00:00.000Z', + firstTradeAt: null, + status: 'PENDING', + earnedXlm: 0, + }, + ]); + }); + + it('creates and returns a referral code for a wallet that has none yet', async () => { + codeFindUnique.mockResolvedValueOnce(null).mockResolvedValueOnce({ code: CODE }); + codeCreate.mockImplementation(async ({ data }: any) => ({ + code: data.code, + })); + eventAggregate.mockResolvedValue({ _sum: { amount: '0' } }); + referralFindMany.mockResolvedValue([]); + mockCounts(0, 0); + + const result = await getReferralEarnings(REFERRER, { limit: 20 }); + + expect(result.referralCode).toHaveLength(REFERRAL_CODE_LENGTH); + expect(codeCreate).toHaveBeenCalledTimes(1); + }); + + it('only aggregates fees for the referees on the current page', async () => { + eventAggregate.mockResolvedValue({ _sum: { amount: '3' } }); + referralFindMany.mockResolvedValue([referralRow({ id: 'ref-1' })]); + eventGroupBy.mockResolvedValue([ + { refereeAddress: REFEREE, _sum: { amount: '3' } }, + ]); + mockCounts(1, 1); + + await getReferralEarnings(REFERRER, { limit: 1 }); + + expect(eventGroupBy).toHaveBeenCalledWith({ + by: ['refereeAddress'], + where: { + walletAddress: REFERRER, + refereeAddress: { in: [REFEREE] }, + }, + _sum: { amount: true }, + }); + }); +}); + +describe('listReferredWallets', () => { + beforeEach(() => { + jest.clearAllMocks(); + eventGroupBy.mockResolvedValue([]); + }); + + it('returns the join date and first-trade status for each referred wallet', async () => { + const firstTradeAt = new Date('2026-09-05T12:00:00.000Z'); + referralFindMany.mockResolvedValue([ + referralRow({ + id: 'ref-1', + refereeAddress: REFEREE, + firstTradeAt, + }), + referralRow({ + id: 'ref-2', + refereeAddress: REFEREE_2, + firstTradeAt: null, + }), + ]); + eventGroupBy.mockResolvedValue([ + { refereeAddress: REFEREE, _sum: { amount: '4.5' } }, + ]); + + const page = await listReferredWallets(REFERRER, { limit: 20 }); + + expect(page.has_more).toBe(false); + expect(page.next_cursor).toBeNull(); + expect(page.items).toEqual([ + { + refereeAddress: REFEREE, + joinedAt: '2026-09-01T10:00:00.000Z', + firstTradeAt: '2026-09-05T12:00:00.000Z', + hasCompletedFirstTrade: true, + status: 'ACTIVE', + earnedXlm: 4.5, + }, + { + refereeAddress: REFEREE_2, + joinedAt: '2026-09-01T10:00:00.000Z', + firstTradeAt: null, + hasCompletedFirstTrade: false, + status: 'PENDING', + earnedXlm: 0, + }, + ]); + }); + + it('orders referred wallets by join time, newest first', async () => { + referralFindMany.mockResolvedValue([]); + + await listReferredWallets(REFERRER, { limit: 20 }); + + expect(referralFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { referrerAddress: REFERRER }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + }) + ); + }); + + it('fetches one extra row to detect whether a next page exists', async () => { + referralFindMany.mockResolvedValue([ + referralRow({ id: 'ref-1', refereeAddress: REFEREE }), + referralRow({ id: 'ref-2', refereeAddress: REFEREE_2 }), + ]); + + const page = await listReferredWallets(REFERRER, { limit: 1 }); + + expect(referralFindMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 2 }) + ); + expect(page.items).toHaveLength(1); + expect(page.has_more).toBe(true); + expect(page.next_cursor).not.toBeNull(); + }); + + it('applies keyset filtering from the supplied cursor', async () => { + referralFindMany.mockResolvedValue([]); + + await listReferredWallets(REFERRER, { + limit: 20, + cursor: encodeCursor({ + joinedAt: '2026-09-01T10:00:00.000Z', + id: 'ref-1', + }), + }); + + expect(referralFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + referrerAddress: REFERRER, + OR: [ + { createdAt: { lt: new Date('2026-09-01T10:00:00.000Z') } }, + { + createdAt: { lte: new Date('2026-09-01T10:00:00.000Z') }, + id: { lt: 'ref-1' }, + }, + ], + }, + }) + ); + }); + + it('rejects a tampered cursor', async () => { + await expect( + listReferredWallets(REFERRER, { limit: 20, cursor: 'tampered.cursor' }) + ).rejects.toThrow(/cursor/i); + expect(referralFindMany).not.toHaveBeenCalled(); + }); +}); + +describe('recordFirstTradeReferralReward', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('records the referral fee against the referrer on the first trade', async () => { + const tradeAt = new Date('2026-09-05T12:00:00.000Z'); + referralUpdateMany.mockResolvedValue({ count: 1 }); + referralFindUnique.mockResolvedValue({ referrerAddress: REFERRER }); + eventCreate.mockResolvedValue({}); + + const paid = await recordFirstTradeReferralReward({ + refereeAddress: REFEREE, + keyId: 'key-1', + tradeValueXlm: 10, + txHash: 'tx-1', + eventIndex: 0, + tradeAt, + }); + + expect(paid).toBe(true); + expect(referralUpdateMany).toHaveBeenCalledWith({ + where: { refereeAddress: REFEREE, firstTradeAt: null }, + data: { firstTradeAt: tradeAt, firstTradeKeyId: 'key-1' }, + }); + // 10 XLM * 500bps = 0.5 XLM + expect(eventCreate).toHaveBeenCalledWith({ + data: { + walletAddress: REFERRER, + refereeAddress: REFEREE, + keyId: 'key-1', + amount: 0.5, + txHash: 'tx-1', + eventIndex: 0, + createdAt: tradeAt, + }, + }); + }); + + it('does not record a fee for a wallet that was never referred', async () => { + referralUpdateMany.mockResolvedValue({ count: 0 }); + + const paid = await recordFirstTradeReferralReward({ + refereeAddress: REFEREE, + keyId: 'key-1', + tradeValueXlm: 10, + }); + + expect(paid).toBe(false); + expect(eventCreate).not.toHaveBeenCalled(); + }); + + it('records a fee only once across repeated trades', async () => { + referralUpdateMany + .mockResolvedValueOnce({ count: 1 }) + .mockResolvedValueOnce({ count: 0 }); + referralFindUnique.mockResolvedValue({ referrerAddress: REFERRER }); + eventCreate.mockResolvedValue({}); + + await expect( + recordFirstTradeReferralReward({ + refereeAddress: REFEREE, + keyId: 'key-1', + tradeValueXlm: 10, + }) + ).resolves.toBe(true); + await expect( + recordFirstTradeReferralReward({ + refereeAddress: REFEREE, + keyId: 'key-2', + tradeValueXlm: 25, + }) + ).resolves.toBe(false); + + expect(eventCreate).toHaveBeenCalledTimes(1); + }); + + it('stamps the first trade but skips the fee row when the reward rounds to zero', async () => { + const bps = envConfig.REFERRAL_REWARD_BPS; + referralUpdateMany.mockResolvedValue({ count: 1 }); + referralFindUnique.mockResolvedValue({ referrerAddress: REFERRER }); + eventCreate.mockResolvedValue({}); + + const paid = await recordFirstTradeReferralReward({ + refereeAddress: REFEREE, + keyId: 'key-1', + tradeValueXlm: 0, + }); + + expect(bps).toBeGreaterThanOrEqual(0); + expect(paid).toBe(true); + expect(referralUpdateMany).toHaveBeenCalled(); + expect(eventCreate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/referrals/referrals.service.ts b/src/modules/referrals/referrals.service.ts new file mode 100644 index 00000000..9b04f712 --- /dev/null +++ b/src/modules/referrals/referrals.service.ts @@ -0,0 +1,500 @@ +// src/modules/referrals/referrals.service.ts +// Referral tracking and reward distribution (#910). +// +// Every wallet owns a referral code (`ReferralCode`). When a new wallet +// registers with that code (`POST /referrals/register`) a `Referral` row links +// the referee to the referrer, permanently: a wallet can be referred exactly +// once. +// +// Rewards are paid once per referee, on the referred wallet's first trade. The +// indexer calls `recordFirstTradeReferralReward` for every buy; the update that +// stamps `firstTradeAt` is conditional on it still being null, so concurrent +// events (or replays) for the same referee can only ever pay out once. The +// payout is written to the `ReferralEvent` fee ledger, which is what the +// earnings endpoint aggregates. + +import crypto from 'crypto'; +import { prisma } from '../../utils/prisma.utils'; +import { logger } from '../../utils/logger.utils'; +import { envConfig } from '../../config'; +import { + buildPaginatedResponse, + PaginatedResponse, +} from '../../utils/pagination.utils'; +import { + decodeCursor, + encodeCursor, + CursorChecksumError, +} from '../../utils/cursor.utils'; +import { + DEFAULT_REFERRED_PAGE_SIZE, + REFERRAL_CODE_ALPHABET, + REFERRAL_CODE_LENGTH, + REFERRAL_CODE_MAX_ATTEMPTS, + XLM_DECIMALS, +} from './referrals.constants'; +import { + ReferredCursorPayload, + ReferredWallet, + ReferralEarnings, + ReferralEarningsBreakdownItem, + ReferralStatus, + ReferredWalletsQuery, +} from './referrals.schemas'; + +/** Thrown when the supplied referral code has no owner. */ +export class ReferralCodeNotFoundError extends Error { + constructor() { + super('Referral code not found'); + this.name = 'ReferralCodeNotFoundError'; + } +} + +/** Thrown when the referee has already been referred by another wallet. */ +export class AlreadyReferredError extends Error { + constructor(refereeAddress: string) { + super(`Wallet ${refereeAddress} has already been referred`); + this.name = 'AlreadyReferredError'; + } +} + +/** Thrown when a wallet tries to register with its own referral code. */ +export class SelfReferralError extends Error { + constructor(wallet: string) { + super(`Wallet ${wallet} cannot refer itself`); + this.name = 'SelfReferralError'; + } +} + +/** + * Detects a Prisma unique-constraint violation (P2002). + * + * Matched on the error code rather than `instanceof` so the check keeps + * working when the Prisma namespace is stubbed out (unit tests) or when a + * different Prisma client instance constructed the error. + */ +function isUniqueViolation(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false; + const { code, name } = error as { code?: unknown; name?: unknown }; + if (code !== 'P2002') return false; + return name === undefined || name === 'PrismaClientKnownRequestError'; +} + +/** Rounds an XLM amount to Stellar's 7 decimal places. */ +export function roundXlm(amount: number): number { + return Number(amount.toFixed(XLM_DECIMALS)); +} + +/** + * Generates a referral code from a CSPRNG. The alphabet excludes characters + * that are easy to confuse when a code is read out loud or copied by hand. + */ +export function generateReferralCode(): string { + const alphabet = REFERRAL_CODE_ALPHABET; + // Rejection sampling keeps the distribution uniform across the alphabet. + const maxUnbiased = Math.floor(256 / alphabet.length) * alphabet.length; + let code = ''; + while (code.length < REFERRAL_CODE_LENGTH) { + const bytes = crypto.randomBytes(REFERRAL_CODE_LENGTH); + for (const byte of bytes) { + if (byte >= maxUnbiased) continue; + code += alphabet[byte % alphabet.length]; + if (code.length === REFERRAL_CODE_LENGTH) break; + } + } + return code; +} + +/** + * Returns the wallet's referral code, creating one on first use so every + * wallet always has a code to share. + */ +export async function getOrCreateReferralCode(wallet: string): Promise { + const address = wallet.trim(); + + for (let attempt = 0; attempt < REFERRAL_CODE_MAX_ATTEMPTS; attempt += 1) { + const existing = await prisma.referralCode.findUnique({ + where: { walletAddress: address }, + select: { code: true }, + }); + if (existing) { + return existing.code; + } + + try { + const created = await prisma.referralCode.create({ + data: { walletAddress: address, code: generateReferralCode() }, + select: { code: true }, + }); + return created.code; + } catch (error) { + // Another request created the row (or claimed the generated code) + // first: re-read on the next iteration instead of failing. + if (!isUniqueViolation(error)) { + throw error; + } + } + } + + // Exhausted the retry budget: surface the last known state if the code row + // exists by now, otherwise fail loudly rather than returning a wrong code. + const existing = await prisma.referralCode.findUnique({ + where: { walletAddress: address }, + select: { code: true }, + }); + if (!existing) { + throw new Error( + `Unable to generate a unique referral code for ${address}` + ); + } + return existing.code; +} + +export interface RegisteredReferral { + referralId: string; + referrerAddress: string; + refereeAddress: string; + referralCode: string; + joinedAt: string; + status: ReferralStatus; +} + +/** + * Links `refereeAddress` to the owner of `code`. + * + * @throws {ReferralCodeNotFoundError} when no wallet owns the code + * @throws {SelfReferralError} when the referee owns the code itself + * @throws {AlreadyReferredError} when the referee was already referred + */ +export async function registerReferral( + refereeAddress: string, + code: string +): Promise { + const referee = refereeAddress.trim(); + const normalizedCode = code.trim().toUpperCase(); + + const referralCode = await prisma.referralCode.findUnique({ + where: { code: normalizedCode }, + select: { walletAddress: true }, + }); + + if (!referralCode) { + throw new ReferralCodeNotFoundError(); + } + + if (referralCode.walletAddress === referee) { + throw new SelfReferralError(referee); + } + + const alreadyReferred = await prisma.referral.findUnique({ + where: { refereeAddress: referee }, + select: { referrerAddress: true }, + }); + if (alreadyReferred) { + throw new AlreadyReferredError(referee); + } + + try { + const referral = await prisma.referral.create({ + data: { + referrerAddress: referralCode.walletAddress, + refereeAddress: referee, + referralCode: normalizedCode, + }, + }); + + logger.info( + { + type: 'referral_registered', + referrerAddress: referral.referrerAddress, + refereeAddress: referral.refereeAddress, + }, + 'Referral relationship registered' + ); + + return { + referralId: referral.id, + referrerAddress: referral.referrerAddress, + refereeAddress: referral.refereeAddress, + referralCode: referral.referralCode, + joinedAt: referral.createdAt.toISOString(), + status: 'PENDING', + }; + } catch (error) { + // Lost a race against a concurrent registration for the same referee: + // the unique index on `refereeAddress` is the source of truth. + if (isUniqueViolation(error)) { + throw new AlreadyReferredError(referee); + } + throw error; + } +} + +function parseReferredCursor( + cursor: string | undefined +): ReferredCursorPayload | null { + if (!cursor) return null; + + let payload: ReferredCursorPayload; + try { + payload = decodeCursor(cursor); + } catch { + throw new CursorChecksumError('Invalid cursor'); + } + + if ( + typeof payload?.joinedAt !== 'string' || + Number.isNaN(new Date(payload.joinedAt).getTime()) || + typeof payload?.id !== 'string' + ) { + throw new CursorChecksumError('Invalid cursor'); + } + + return payload; +} + +function toStatus(firstTradeAt: Date | null): ReferralStatus { + return firstTradeAt ? 'ACTIVE' : 'PENDING'; +} + +/** + * Sums the referral fees earned per referred wallet, restricted to the + * referees on the current page so the aggregate never scans the whole ledger. + */ +async function sumEarnedByReferee( + referrerAddress: string, + refereeAddresses: string[] +): Promise> { + if (refereeAddresses.length === 0) { + return new Map(); + } + + const grouped = await prisma.referralEvent.groupBy({ + by: ['refereeAddress'], + where: { + walletAddress: referrerAddress, + refereeAddress: { in: refereeAddresses }, + }, + _sum: { amount: true }, + }); + + const earned = new Map(); + for (const row of grouped) { + if (!row.refereeAddress) continue; + earned.set(row.refereeAddress, Number(row._sum.amount ?? 0)); + } + return earned; +} + +/** + * One cursor-paginated page of a wallet's referred wallets, newest first, + * annotated with the reward each referee has earned so far. + */ +export async function listReferredWallets( + referrerAddress: string, + query: ReferredWalletsQuery = { limit: DEFAULT_REFERRED_PAGE_SIZE } +): Promise> { + const cursor = parseReferredCursor(query.cursor); + const limit = query.limit ?? DEFAULT_REFERRED_PAGE_SIZE; + + const rows = await prisma.referral.findMany({ + where: { + referrerAddress, + ...(cursor + ? { + OR: [ + { createdAt: { lt: new Date(cursor.joinedAt) } }, + { + createdAt: { lte: new Date(cursor.joinedAt) }, + id: { lt: cursor.id }, + }, + ], + } + : {}), + }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: limit + 1, + }); + + const earned = await sumEarnedByReferee( + referrerAddress, + rows.map((row) => row.refereeAddress) + ); + + // `refereeAddress` is unique per referral, so it doubles as the key that + // lets the cursor builder recover the row id of the page's last item. + const cursorByReferee = new Map( + rows.map((row) => [ + row.refereeAddress, + encodeCursor({ + joinedAt: row.createdAt.toISOString(), + id: row.id, + }), + ]) + ); + + const items: ReferredWallet[] = rows.map((row) => ({ + refereeAddress: row.refereeAddress, + joinedAt: row.createdAt.toISOString(), + firstTradeAt: row.firstTradeAt ? row.firstTradeAt.toISOString() : null, + hasCompletedFirstTrade: row.firstTradeAt !== null, + status: toStatus(row.firstTradeAt), + earnedXlm: roundXlm(earned.get(row.refereeAddress) ?? 0), + })); + + return buildPaginatedResponse( + items, + limit, + (item) => cursorByReferee.get(item.refereeAddress) ?? '' + ); +} + +export interface ReferralEarningsResult extends ReferralEarnings { + breakdown: ReferralEarningsBreakdownItem[]; + pagination: { + limit: number; + nextCursor: string | null; + hasMore: boolean; + }; +} + +/** + * Total XLM earned by a wallet from referrals plus a per-referral breakdown. + * + * `totalEarned` is summed from the `ReferralEvent` fee ledger so it also + * accounts for fees that were not attributed to a specific referee. The + * breakdown covers the referee's registered referrals on the current page. + */ +export async function getReferralEarnings( + referrerAddress: string, + query: ReferredWalletsQuery = { limit: DEFAULT_REFERRED_PAGE_SIZE } +): Promise { + const limit = query.limit ?? DEFAULT_REFERRED_PAGE_SIZE; + + const [referralCode, aggregate, referredCount, rewardedReferralCount, page] = + await Promise.all([ + getOrCreateReferralCode(referrerAddress), + prisma.referralEvent.aggregate({ + where: { walletAddress: referrerAddress }, + _sum: { amount: true }, + }), + prisma.referral.count({ where: { referrerAddress } }), + prisma.referral.count({ + where: { referrerAddress, firstTradeAt: { not: null } }, + }), + listReferredWallets(referrerAddress, { ...query, limit }), + ]); + + return { + referralCode, + totalEarned: roundXlm(Number(aggregate._sum.amount ?? 0)), + rewardedReferralCount, + referredCount, + breakdown: page.items.map((item) => ({ + refereeAddress: item.refereeAddress, + joinedAt: item.joinedAt, + firstTradeAt: item.firstTradeAt, + status: item.status, + earnedXlm: item.earnedXlm, + })), + pagination: { + limit, + nextCursor: page.next_cursor, + hasMore: page.has_more, + }, + }; +} + +export interface FirstTradeReferralInput { + /** Wallet that executed the trade. */ + refereeAddress: string; + /** Key (creator id) that was traded. */ + keyId: string; + /** Total XLM value of the trade, used to size the reward. */ + tradeValueXlm: number; + txHash?: string | null; + eventIndex?: number | null; + tradeAt?: Date; +} + +/** + * Pays the referrer a share of a referred wallet's first trade. + * + * The `firstTradeAt` stamp is conditional on it still being null, so only the + * first trade a referred wallet completes ever pays out — later trades and + * indexer replays are no-ops. + * + * @returns true when a reward was paid, false when the wallet was not + * referred or had already traded. + */ +export async function recordFirstTradeReferralReward( + input: FirstTradeReferralInput +): Promise { + const refereeAddress = input.refereeAddress.trim(); + const tradeAt = input.tradeAt ?? new Date(); + const rewardXlm = roundXlm( + (input.tradeValueXlm * envConfig.REFERRAL_REWARD_BPS) / 10_000 + ); + + // Claim the first trade and write its fee in one transaction. If the fee + // insert fails, the first-trade stamp rolls back too, allowing a replay to + // retry instead of permanently losing the reward. + const referrerAddress = await prisma.$transaction(async (tx) => { + const claimed = await tx.referral.updateMany({ + where: { refereeAddress, firstTradeAt: null }, + data: { firstTradeAt: tradeAt, firstTradeKeyId: input.keyId }, + }); + + // `count === 0` means the wallet is not referred, or a previous event + // already claimed the reward. + if (claimed.count === 0) { + return null; + } + + const referral = await tx.referral.findUnique({ + where: { refereeAddress }, + select: { referrerAddress: true }, + }); + if (!referral) { + throw new Error( + `Referral for wallet ${refereeAddress} disappeared while claiming its first trade` + ); + } + + if (rewardXlm > 0) { + await tx.referralEvent.create({ + data: { + walletAddress: referral.referrerAddress, + refereeAddress, + keyId: input.keyId, + amount: rewardXlm, + ...(input.txHash ? { txHash: input.txHash } : {}), + ...(input.eventIndex !== undefined && input.eventIndex !== null + ? { eventIndex: input.eventIndex } + : {}), + createdAt: tradeAt, + }, + }); + } + + return referral.referrerAddress; + }); + + if (!referrerAddress) { + return false; + } + + logger.info( + { + type: 'referral_first_trade_reward', + referrerAddress, + refereeAddress, + keyId: input.keyId, + rewardXlm, + ...(input.txHash ? { txHash: input.txHash } : {}), + }, + 'Referral reward paid for referred wallet first trade' + ); + + return true; +}