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,89 @@
import type { Affiliate } from '@prisma/client'

import type { PrismaService } from '../../prisma/prisma.service'
import { AffiliateService } from '../affiliate.service'

type MockAffiliateDelegate = {
findUnique: jest.Mock
create: jest.Mock
}

const makePrismaMock = (overrides?: Partial<MockAffiliateDelegate>): PrismaService => {
const affiliate: MockAffiliateDelegate = {
findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn(),
...overrides,
}
return { affiliate } as unknown as PrismaService
}

const baseRow = (overrides?: Partial<Affiliate>): Affiliate => ({
id: 'a1',
walletAddress: '0x1111111111111111111111111111111111111111',
receiveAddress: null,
partnerCode: 'goodcode',
bps: 60,
isActive: true,
createdAt: new Date('2026-05-28T00:00:00.000Z'),
updatedAt: new Date('2026-05-28T00:00:00.000Z'),
...overrides,
})

describe('AffiliateService.createAffiliate', () => {
const wallet = '0x1111111111111111111111111111111111111111'

it('creates a row when wallet and code are unused', async () => {
const create = jest.fn().mockResolvedValue(baseRow())
const prisma = makePrismaMock({ create })
const service = new AffiliateService(prisma)

const result = await service.createAffiliate({
walletAddress: wallet,
partnerCode: 'goodcode',
bps: 75,
})

expect(create).toHaveBeenCalledWith({
data: {
walletAddress: wallet,
receiveAddress: undefined,
partnerCode: 'goodcode',
bps: 75,
},
})
expect(result.walletAddress).toBe(wallet)
})

it('rejects when wallet is already registered', async () => {
const prisma = makePrismaMock({
findUnique: jest.fn().mockResolvedValueOnce(baseRow()),
})
const service = new AffiliateService(prisma)

await expect(service.createAffiliate({ walletAddress: wallet, partnerCode: 'newcode', bps: 60 })).rejects.toThrow(
/already/,
)
})

it('rejects reserved partner codes (case-insensitive)', async () => {
const prisma = makePrismaMock()
const service = new AffiliateService(prisma)

await expect(service.createAffiliate({ walletAddress: wallet, partnerCode: 'ADMIN', bps: 60 })).rejects.toThrow(
/reserved/,
)
})

it('rejects when partner code is already taken', async () => {
const findUnique = jest
.fn()
.mockResolvedValueOnce(null) // wallet lookup
.mockResolvedValueOnce(baseRow({ walletAddress: '0xother', partnerCode: 'goodcode' }))
const prisma = makePrismaMock({ findUnique })
const service = new AffiliateService(prisma)

await expect(service.createAffiliate({ walletAddress: wallet, partnerCode: 'goodcode', bps: 60 })).rejects.toThrow(
/taken/,
)
})
})
33 changes: 5 additions & 28 deletions apps/swap-service/src/affiliate/affiliate.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,7 @@ import { SHAPESHIFT_BPS } from '../swaps/constants'

import { AffiliateService } from './affiliate.service'
import { SiweAuthGuard, SiweRequest } from './siwe-auth.guard'
import {
AffiliateStatsQueryDto,
AffiliateSwapsQueryDto,
ClaimPartnerCodeDto,
CreateAffiliateDto,
UpdateAffiliateDto,
} from './types'
import { AffiliateStatsQueryDto, AffiliateSwapsQueryDto, CreateAffiliateDto, UpdateAffiliateDto } from './types'
import { assertSiweMatches } from './utils'

const toAffiliateResponse = (affiliate: Affiliate) => {
Expand Down Expand Up @@ -62,7 +56,10 @@ export class AffiliateController {
return toAffiliateResponse(await this.affiliateService.createAffiliate(data))
} catch (error) {
if (error instanceof Error) {
if (error.message.includes('already')) throw new ConflictException(error.message)
if (error.message.includes('reserved')) throw new BadRequestException(error.message)
if (error.message.includes('already') || error.message.includes('taken')) {
throw new ConflictException(error.message)
}
}
throw error
}
Expand All @@ -75,26 +72,6 @@ export class AffiliateController {

return toAffiliateResponse(await this.affiliateService.updateAffiliate(address, data))
}

@UseGuards(SiweAuthGuard)
@Post('claim-code')
async claimPartnerCode(@Req() req: SiweRequest, @Body() data: ClaimPartnerCodeDto) {
assertSiweMatches(req, data.walletAddress, 'Authenticated address does not match walletAddress')

try {
return toAffiliateResponse(await this.affiliateService.claimPartnerCode(data.walletAddress, data.partnerCode))
} catch (error) {
if (error instanceof Error) {
if (error.message.includes('taken') || error.message.includes('reserved')) {
throw new ConflictException(error.message)
}
if (error.message.includes('must be')) {
throw new BadRequestException(error.message)
}
}
throw error
}
}
}

@Controller('v1/partner')
Expand Down
29 changes: 8 additions & 21 deletions apps/swap-service/src/affiliate/affiliate.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type {
CreateAffiliateDto,
UpdateAffiliateDto,
} from './types'
import { RESERVED_PARTNER_CODES } from './utils'
import { isReservedPartnerCode } from './utils'

@Injectable()
export class AffiliateService {
Expand All @@ -38,12 +38,14 @@ export class AffiliateService {
const existing = await this.prisma.affiliate.findUnique({ where: { walletAddress } })
if (existing) throw new Error('Affiliate already registered')

if (data.partnerCode) {
const existingCode = await this.prisma.affiliate.findUnique({ where: { partnerCode } })
if (existingCode) throw new Error('Partner code already taken')
}
if (isReservedPartnerCode(partnerCode)) throw new Error('This partner code is reserved')

const existingCode = await this.prisma.affiliate.findUnique({ where: { partnerCode } })
if (existingCode) throw new Error('Partner code already taken')

return this.prisma.affiliate.create({ data: { walletAddress, receiveAddress, partnerCode, bps: bps ?? 60 } })
return this.prisma.affiliate.create({
data: { walletAddress, receiveAddress, partnerCode, bps },
})
}

async updateAffiliate(walletAddress: string, data: UpdateAffiliateDto): Promise<Affiliate> {
Expand All @@ -62,21 +64,6 @@ export class AffiliateService {
return this.prisma.affiliate.update({ where: { walletAddress }, data: updateData })
}

async claimPartnerCode(walletAddress: string, partnerCode: string): Promise<Affiliate> {
if (RESERVED_PARTNER_CODES.includes(partnerCode.toLowerCase())) throw new Error('This partner code is reserved')

const existingCode = await this.prisma.affiliate.findUnique({ where: { partnerCode } })
if (existingCode && existingCode.walletAddress.toLowerCase() !== walletAddress.toLowerCase()) {
throw new Error('Partner code already taken')
}

return this.prisma.affiliate.upsert({
where: { walletAddress },
update: { partnerCode },
create: { walletAddress, partnerCode, bps: 60 },
})
}

async getAffiliateStats(address: string, options: AffiliateStatsQueryDto): Promise<AffiliateStatsResult> {
const { startDate, endDate } = options

Expand Down
16 changes: 3 additions & 13 deletions apps/swap-service/src/affiliate/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { PaginationQueryDto } from '../swaps/types'

import { PARTNER_CODE_REGEX } from './utils'

const PARTNER_CODE_MESSAGE = 'Partner code must be 3-32 alphanumeric characters or hyphens'
const PARTNER_CODE_MESSAGE = 'Partner code must be 3-32 lowercase letters or numbers (e.g. mypartnercode)'

export interface AffiliateStatsResult {
totalSwaps: number
Expand Down Expand Up @@ -53,15 +53,13 @@ export class CreateAffiliateDto {
@IsEthereumAddress()
receiveAddress?: string

@IsOptional()
@Matches(PARTNER_CODE_REGEX, { message: PARTNER_CODE_MESSAGE })
partnerCode?: string
partnerCode: string

@IsOptional()
@IsInt()
@Min(0)
@Max(1000)
bps?: number
bps: number
}

export class UpdateAffiliateDto {
Expand All @@ -79,11 +77,3 @@ export class UpdateAffiliateDto {
@IsBoolean()
isActive?: boolean
}

export class ClaimPartnerCodeDto {
@IsEthereumAddress()
walletAddress: string

@Matches(PARTNER_CODE_REGEX, { message: PARTNER_CODE_MESSAGE })
partnerCode: string
}
26 changes: 24 additions & 2 deletions apps/swap-service/src/affiliate/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,30 @@ import { ForbiddenException } from '@nestjs/common'

import type { SiweRequest } from './siwe-auth.guard'

export const PARTNER_CODE_REGEX = /^[a-zA-Z0-9-]{3,32}$/
export const RESERVED_PARTNER_CODES = ['ss', 'admin', 'api', 'test', 'demo']
export const PARTNER_CODE_REGEX = /^[a-z0-9]{3,32}$/

const RESERVED_PARTNER_CODES = [
'admin',
'api',
'auth',
'demo',
'dev',
'develop',
'prod',
'production',
'release',
'ss',
'system',
'test',
]

const RESERVED_PARTNER_CODE_SUBSTRINGS = ['shapeshift']

export function isReservedPartnerCode(partnerCode: string): boolean {
const code = partnerCode.toLowerCase()
if (RESERVED_PARTNER_CODES.includes(code)) return true
return RESERVED_PARTNER_CODE_SUBSTRINGS.some((substring) => code.includes(substring))
}

export function assertSiweMatches(req: SiweRequest, target: string, message: string): void {
if (req.siweAddress !== target.toLowerCase()) throw new ForbiddenException(message)
Expand Down
Loading