From d75f10c06412a07b3cb52026c888ed5766ed6a34 Mon Sep 17 00:00:00 2001 From: Daniel Constantin Date: Wed, 29 Apr 2026 14:56:28 +0300 Subject: [PATCH 1/5] feat(affiliate): add trader activity endpoint --- .env.example | 3 + .../trader-activity/_address/index.ts | 44 ++++ .../_address/traderActivity.schemas.ts | 74 +++++++ .../src/app/routes/ref-codes/_code/index.ts | 2 + .../AffiliateStatsService.config.ts | 12 +- .../AffiliateStatsService.ts | 44 ++++ .../AffiliateStatsService.types.ts | 6 +- .../AffiliateStatsService.utils.ts | 89 +++++++- .../AffiliateStatsServiceImpl.spec.ts | 199 ++++++++++++++++++ .../AffiliateStatsServiceImpl.ts | 47 ++++- 10 files changed, 515 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/app/routes/affiliate/trader-activity/_address/index.ts create mode 100644 apps/api/src/app/routes/affiliate/trader-activity/_address/traderActivity.schemas.ts create mode 100644 libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.spec.ts diff --git a/.env.example b/.env.example index a4831107..1101989b 100644 --- a/.env.example +++ b/.env.example @@ -98,6 +98,9 @@ #NOTIFICATIONS_PRODUCER_CHAINS=1,100 #DUNE_API_KEY= +#DUNE_QUERY_ID_TRADER_STATS= +#DUNE_QUERY_ID_TRADER_ACTIVITY= +#DUNE_QUERY_ID_AFFILIATE_STATS= # Socket # SOCKET_BASE_URL= diff --git a/apps/api/src/app/routes/affiliate/trader-activity/_address/index.ts b/apps/api/src/app/routes/affiliate/trader-activity/_address/index.ts new file mode 100644 index 00000000..a92721f1 --- /dev/null +++ b/apps/api/src/app/routes/affiliate/trader-activity/_address/index.ts @@ -0,0 +1,44 @@ +import { FastifyPluginAsync } from 'fastify' +import { FromSchema } from 'json-schema-to-ts' +import { apiContainer } from '../../../../inversify.config' +import { AffiliateStatsService, affiliateStatsServiceSymbol } from '@cowprotocol/services' +import { isDuneEnabled } from '@cowprotocol/repositories' +import { errorSchema, paramsSchema, responseSchema } from './traderActivity.schemas' + +type ParamsSchema = FromSchema +type ResponseSchema = FromSchema | FromSchema + +const traderActivity: FastifyPluginAsync = async (fastify): Promise => { + if (!isDuneEnabled) { + fastify.log.warn('DUNE_API_KEY is not set. Skipping affiliate trader activity endpoint.') + return + } + + fastify.get<{ Params: ParamsSchema; Reply: ResponseSchema }>( + '/', + { + schema: { + description: 'Get affiliate trader activity from Dune Analytics', + tags: ['affiliate'], + params: paramsSchema, + response: { + 200: responseSchema, + 500: errorSchema, + }, + }, + }, + async function (request, reply) { + try { + const affiliateStatsService = apiContainer.get(affiliateStatsServiceSymbol) + const result = await affiliateStatsService.getTraderActivity(request.params.address) + + return reply.send(result) + } catch (error) { + fastify.log.error({ err: error }, 'Error fetching affiliate trader activity') + return reply.status(500).send({ message: 'Unexpected error' }) + } + } + ) +} + +export default traderActivity diff --git a/apps/api/src/app/routes/affiliate/trader-activity/_address/traderActivity.schemas.ts b/apps/api/src/app/routes/affiliate/trader-activity/_address/traderActivity.schemas.ts new file mode 100644 index 00000000..f510502e --- /dev/null +++ b/apps/api/src/app/routes/affiliate/trader-activity/_address/traderActivity.schemas.ts @@ -0,0 +1,74 @@ +import { JSONSchema } from 'json-schema-to-ts' +import { AddressSchema } from '../../../../schemas' + +export const paramsSchema = { + type: 'object', + required: ['address'], + additionalProperties: false, + properties: { + address: AddressSchema, + }, +} as const satisfies JSONSchema + +export const traderActivityRowSchema = { + type: 'object', + required: [ + 'chain_id', + 'creation_date', + 'tx_hash', + 'order_uid', + 'trader_address', + 'sell_token', + 'buy_token', + 'executed_sell_amount', + 'executed_buy_amount', + 'usd_value', + 'eligible_volume_usd', + 'referrer_code', + 'bound_referrer_code', + 'eligibility_reason', + 'is_bound_to_code', + 'is_eligible', + ], + additionalProperties: false, + properties: { + chain_id: { type: 'number' }, + creation_date: { type: 'string' }, + tx_hash: { type: 'string' }, + order_uid: { type: 'string' }, + trader_address: { type: 'string' }, + sell_token: { type: 'string' }, + buy_token: { type: 'string' }, + executed_sell_amount: { type: 'string' }, + executed_buy_amount: { type: 'string' }, + usd_value: { type: 'number' }, + eligible_volume_usd: { type: 'number' }, + referrer_code: { type: 'string' }, + bound_referrer_code: { type: 'string' }, + eligibility_reason: { type: 'string' }, + is_bound_to_code: { type: 'boolean' }, + is_eligible: { type: 'boolean' }, + }, +} as const satisfies JSONSchema + +export const responseSchema = { + type: 'object', + required: ['rows', 'lastUpdatedAt'], + additionalProperties: false, + properties: { + rows: { + type: 'array', + items: traderActivityRowSchema, + }, + lastUpdatedAt: { type: 'string' }, + }, +} as const satisfies JSONSchema + +export const errorSchema = { + type: 'object', + required: ['message'], + additionalProperties: false, + properties: { + message: { type: 'string' }, + }, +} as const satisfies JSONSchema diff --git a/apps/api/src/app/routes/ref-codes/_code/index.ts b/apps/api/src/app/routes/ref-codes/_code/index.ts index cca0f835..16897811 100644 --- a/apps/api/src/app/routes/ref-codes/_code/index.ts +++ b/apps/api/src/app/routes/ref-codes/_code/index.ts @@ -95,6 +95,8 @@ function isValidCode(value: string): boolean { } function handleCmsError(error: unknown, reply: FastifyReply) { + logger.error({ error }, 'Affiliate program failed to get ref code') + if (isCmsRequestError(error)) { reply.code(502).send({ message: 'CMS request failed' }) return diff --git a/libs/services/src/AffiliateStatsService/AffiliateStatsService.config.ts b/libs/services/src/AffiliateStatsService/AffiliateStatsService.config.ts index dd040399..ab7da9d1 100644 --- a/libs/services/src/AffiliateStatsService/AffiliateStatsService.config.ts +++ b/libs/services/src/AffiliateStatsService/AffiliateStatsService.config.ts @@ -1,5 +1,6 @@ export function getDuneQueryIds(): { traderStats: number + traderActivity: number affiliateStats: number } { const traderRaw = process.env.DUNE_QUERY_ID_TRADER_STATS @@ -11,6 +12,15 @@ export function getDuneQueryIds(): { throw new Error('DUNE_QUERY_ID_TRADER_STATS must be an integer') } + const traderActivityRaw = process.env.DUNE_QUERY_ID_TRADER_ACTIVITY + if (!traderActivityRaw) { + throw new Error('DUNE_QUERY_ID_TRADER_ACTIVITY is not set') + } + const traderActivity = Number.parseInt(traderActivityRaw, 10) + if (Number.isNaN(traderActivity)) { + throw new Error('DUNE_QUERY_ID_TRADER_ACTIVITY must be an integer') + } + const affiliateRaw = process.env.DUNE_QUERY_ID_AFFILIATE_STATS if (!affiliateRaw) { throw new Error('DUNE_QUERY_ID_AFFILIATE_STATS is not set') @@ -20,7 +30,7 @@ export function getDuneQueryIds(): { throw new Error('DUNE_QUERY_ID_AFFILIATE_STATS must be an integer') } - return { traderStats, affiliateStats } + return { traderStats, traderActivity, affiliateStats } } export const DUNE_PAGE_SIZE = 1000 as const diff --git a/libs/services/src/AffiliateStatsService/AffiliateStatsService.ts b/libs/services/src/AffiliateStatsService/AffiliateStatsService.ts index 7e71588d..df2bc374 100644 --- a/libs/services/src/AffiliateStatsService/AffiliateStatsService.ts +++ b/libs/services/src/AffiliateStatsService/AffiliateStatsService.ts @@ -13,6 +13,44 @@ export interface TraderStatsRow { next_payout: T } +export interface TraderActivityDuneRow { + chain_id: number + creation_date: string + tx_hash: string + order_uid: string + trader_address: string + sell_token: string + buy_token: string + executed_sell_amount: string + executed_buy_amount: string + usd_value: T + eligible_volume_usd: T + referrer_code: string + bound_referrer_code: string + eligibility_reason: string + is_bound_to_code: boolean + is_eligible: boolean +} + +export interface TraderActivityRow { + chain_id: number + creation_date: string + tx_hash: string + order_uid: string + trader_address: string + sell_token: string + buy_token: string + executed_sell_amount: string + executed_buy_amount: string + usd_value: T + eligible_volume_usd: T + referrer_code: string + bound_referrer_code: string + eligibility_reason: string + is_bound_to_code: boolean + is_eligible: boolean +} + export interface AffiliateStatsRow { affiliate_address: string referrer_code: string @@ -36,7 +74,13 @@ export interface TraderStatsResult { lastUpdatedAt: string } +export interface TraderActivityResult { + rows: TraderActivityRow[] + lastUpdatedAt: string +} + export interface AffiliateStatsService { getTraderStats(address: string): Promise + getTraderActivity(address: string): Promise getAffiliateStats(address: string): Promise } diff --git a/libs/services/src/AffiliateStatsService/AffiliateStatsService.types.ts b/libs/services/src/AffiliateStatsService/AffiliateStatsService.types.ts index c15e3397..77985d55 100644 --- a/libs/services/src/AffiliateStatsService/AffiliateStatsService.types.ts +++ b/libs/services/src/AffiliateStatsService/AffiliateStatsService.types.ts @@ -1,4 +1,4 @@ -import type { AffiliateStatsRow, TraderStatsRow } from './AffiliateStatsService' +import type { AffiliateStatsRow, TraderActivityDuneRow, TraderStatsRow } from './AffiliateStatsService' export interface CacheEntry { expiresAt: number @@ -10,4 +10,8 @@ export type NumericValue = number | string export type TraderStatsRowRaw = TraderStatsRow +export type TraderActivityRowRaw = Omit, 'chain_id'> & { + blockchain: string +} + export type AffiliateStatsRowRaw = AffiliateStatsRow diff --git a/libs/services/src/AffiliateStatsService/AffiliateStatsService.utils.ts b/libs/services/src/AffiliateStatsService/AffiliateStatsService.utils.ts index 183fc9c3..27d71d10 100644 --- a/libs/services/src/AffiliateStatsService/AffiliateStatsService.utils.ts +++ b/libs/services/src/AffiliateStatsService/AffiliateStatsService.utils.ts @@ -1,7 +1,25 @@ -import type { AffiliateStatsRow, TraderStatsRow } from './AffiliateStatsService' -import type { AffiliateStatsRowRaw, TraderStatsRowRaw } from './AffiliateStatsService.types' +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { BigNumber } from 'bignumber.js' +import type { AffiliateStatsRow, TraderActivityDuneRow, TraderStatsRow } from './AffiliateStatsService' +import type { AffiliateStatsRowRaw, TraderActivityRowRaw, TraderStatsRowRaw } from './AffiliateStatsService.types' import { isNumeric, isRecord, isString, toNumber } from '../utils/type-checking-utils' +const BLOCKCHAIN_TO_CHAIN_ID: Record = { + arbitrum: SupportedChainId.ARBITRUM_ONE, + arbitrum_one: SupportedChainId.ARBITRUM_ONE, + avalanche: SupportedChainId.AVALANCHE, + avalanche_c: SupportedChainId.AVALANCHE, + base: SupportedChainId.BASE, + bnb: SupportedChainId.BNB, + ethereum: SupportedChainId.MAINNET, + ink: SupportedChainId.INK, + gnosis: SupportedChainId.GNOSIS_CHAIN, + linea: SupportedChainId.LINEA, + mainnet: SupportedChainId.MAINNET, + plasma: SupportedChainId.PLASMA, + polygon: SupportedChainId.POLYGON, +} + export function isTraderStatsRowRaw(data: unknown): data is TraderStatsRowRaw { if (!isRecord(data)) { return false @@ -21,6 +39,31 @@ export function isTraderStatsRowRaw(data: unknown): data is TraderStatsRowRaw { ) } +export function isTraderActivityRowRaw(data: unknown): data is TraderActivityRowRaw { + if (!isRecord(data)) { + return false + } + + return ( + isString(data.blockchain) && + isString(data.creation_date) && + isString(data.tx_hash) && + isString(data.order_uid) && + isString(data.trader_address) && + isString(data.sell_token) && + isString(data.buy_token) && + isNumeric(data.executed_sell_amount) && + isNumeric(data.executed_buy_amount) && + isNumeric(data.usd_value) && + isNumeric(data.eligible_volume_usd) && + isString(data.referrer_code) && + isString(data.bound_referrer_code) && + isString(data.eligibility_reason) && + typeof data.is_bound_to_code === 'boolean' && + typeof data.is_eligible === 'boolean' + ) +} + export function isAffiliateStatsRowRaw(data: unknown): data is AffiliateStatsRowRaw { if (!isRecord(data)) { return false @@ -55,6 +98,27 @@ export function normalizeTraderStatsRow(row: TraderStatsRowRaw): TraderStatsRow } } +export function normalizeTraderActivityRow(row: TraderActivityRowRaw): TraderActivityDuneRow { + return { + chain_id: getChainId(row.blockchain), + creation_date: row.creation_date, + tx_hash: row.tx_hash, + order_uid: row.order_uid, + trader_address: row.trader_address, + sell_token: row.sell_token, + buy_token: row.buy_token, + executed_sell_amount: toDecimalString(row.executed_sell_amount, 'executed_sell_amount'), + executed_buy_amount: toDecimalString(row.executed_buy_amount, 'executed_buy_amount'), + usd_value: toNumber(row.usd_value, 'usd_value'), + eligible_volume_usd: toNumber(row.eligible_volume_usd, 'eligible_volume_usd'), + referrer_code: row.referrer_code, + bound_referrer_code: row.bound_referrer_code, + eligibility_reason: row.eligibility_reason, + is_bound_to_code: row.is_bound_to_code, + is_eligible: row.is_eligible, + } +} + export function normalizeAffiliateStatsRow(row: AffiliateStatsRowRaw): AffiliateStatsRow { return { affiliate_address: row.affiliate_address, @@ -69,3 +133,24 @@ export function normalizeAffiliateStatsRow(row: AffiliateStatsRowRaw): Affiliate total_traders: toNumber(row.total_traders, 'total_traders'), } } + +function getChainId(blockchain: string): SupportedChainId { + const normalizedBlockchain = blockchain.trim().toLowerCase() + const chainId = BLOCKCHAIN_TO_CHAIN_ID[normalizedBlockchain] + + if (!chainId) { + throw new Error(`Unsupported affiliate trader activity blockchain: ${blockchain}`) + } + + return chainId +} + +function toDecimalString(value: number | string, fieldName: string): string { + const result = new BigNumber(value) + + if (!result.isFinite()) { + throw new Error(`Invalid ${fieldName}: ${value}`) + } + + return result.toFixed() +} diff --git a/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.spec.ts b/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.spec.ts new file mode 100644 index 00000000..2707bc59 --- /dev/null +++ b/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.spec.ts @@ -0,0 +1,199 @@ +import type { + DuneExecutionResponse, + DuneRepository, + DuneResultResponse, + UploadCsvParams, + UploadCsvResponse, +} from '@cowprotocol/repositories' +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import type { TraderActivityRowRaw } from './AffiliateStatsService.types' +import { AffiliateStatsServiceImpl } from './AffiliateStatsServiceImpl' + +class MockDuneRepository implements DuneRepository { + public readonly executeQueryMock = jest.fn< + Promise, + Parameters + >() + public readonly getQueryResultsMock = jest.fn>, [unknown]>() + public readonly getExecutionResultsMock = jest.fn>, [unknown]>() + public readonly waitForExecutionMock = jest.fn>, [unknown]>() + public readonly uploadCsvMock = jest.fn, [UploadCsvParams]>() + + constructor() { + this.executeQueryMock.mockResolvedValue({ + execution_id: 'execution-1', + state: 'QUERY_STATE_PENDING', + }) + + this.getQueryResultsMock.mockResolvedValue(this.createResult([])) + this.getExecutionResultsMock.mockResolvedValue(this.createResult([])) + this.waitForExecutionMock.mockResolvedValue(this.createResult([])) + this.uploadCsvMock.mockResolvedValue({ success: true }) + } + + createResult(rows: TraderActivityRowRaw[]): DuneResultResponse { + return { + execution_id: 'execution-1', + query_id: 123, + is_execution_finished: true, + state: 'QUERY_STATE_COMPLETED', + submitted_at: '2026-03-18T10:00:00.000Z', + expires_at: '2026-03-19T10:00:00.000Z', + execution_started_at: '2026-03-18T10:00:01.000Z', + execution_ended_at: '2026-03-18T10:00:02.000Z', + result: { + rows, + metadata: { + column_names: [], + column_types: [], + row_count: rows.length, + result_set_bytes: 0, + total_row_count: rows.length, + total_result_set_bytes: 0, + datapoint_count: rows.length, + pending_time_millis: 0, + execution_time_millis: 0, + }, + }, + } + } + + executeQuery(...params: Parameters): Promise { + return this.executeQueryMock(...params) + } + + async getQueryResults(params: Parameters[0]): Promise> { + return (await this.getQueryResultsMock(params)) as DuneResultResponse + } + + async getExecutionResults( + params: Parameters[0] + ): Promise> { + return (await this.getExecutionResultsMock(params)) as DuneResultResponse + } + + async waitForExecution(params: Parameters[0]): Promise> { + return (await this.waitForExecutionMock(params)) as DuneResultResponse + } + + uploadCsv(params: UploadCsvParams): Promise { + return this.uploadCsvMock(params) + } +} + +function createTraderActivityRowRaw(overrides: Partial = {}): TraderActivityRowRaw { + return { + blockchain: 'ethereum', + creation_date: '2026-03-18 10:00:00.000 UTC', + tx_hash: '0x123', + order_uid: 'uid-1', + trader_address: '0xabc', + sell_token: '0xsell-dune', + buy_token: '0xbuy-dune', + executed_sell_amount: '0.04667962868331909', + executed_buy_amount: '0.00000123', + usd_value: '123.45', + eligible_volume_usd: '120.12', + referrer_code: 'CODE', + bound_referrer_code: 'CODE', + eligibility_reason: 'Eligible trade', + is_bound_to_code: true, + is_eligible: true, + ...overrides, + } +} + +describe('AffiliateStatsServiceImpl', () => { + const originalEnv = process.env + + beforeEach(() => { + jest.resetModules() + process.env = { + ...originalEnv, + DUNE_QUERY_ID_TRADER_STATS: '101', + DUNE_QUERY_ID_TRADER_ACTIVITY: '102', + DUNE_QUERY_ID_AFFILIATE_STATS: '103', + } + }) + + afterAll(() => { + process.env = originalEnv + }) + + it('fetches trader activity from latest Dune query results', async () => { + const duneRepository = new MockDuneRepository() + const row = createTraderActivityRowRaw() + duneRepository.getQueryResultsMock.mockResolvedValue(duneRepository.createResult([row])) + + const service = new AffiliateStatsServiceImpl(duneRepository, 60_000) + const result = await service.getTraderActivity('0xAbC') + + expect(duneRepository.getQueryResultsMock).toHaveBeenCalledWith({ + queryId: 102, + limit: 1000, + offset: 0, + typeAssertion: expect.any(Function), + }) + expect(duneRepository.executeQueryMock).not.toHaveBeenCalled() + expect(duneRepository.waitForExecutionMock).not.toHaveBeenCalled() + expect(result.lastUpdatedAt).toBe('2026-03-18T10:00:01.000Z') + expect(result.rows).toEqual([ + { + chain_id: SupportedChainId.MAINNET, + creation_date: row.creation_date, + tx_hash: row.tx_hash, + order_uid: row.order_uid, + trader_address: row.trader_address, + sell_token: row.sell_token, + buy_token: row.buy_token, + executed_sell_amount: '0.04667962868331909', + executed_buy_amount: '0.00000123', + usd_value: 123.45, + eligible_volume_usd: 120.12, + referrer_code: row.referrer_code, + bound_referrer_code: row.bound_referrer_code, + eligibility_reason: row.eligibility_reason, + is_bound_to_code: true, + is_eligible: true, + }, + ]) + }) + + it('returns empty trader activity rows', async () => { + const duneRepository = new MockDuneRepository() + duneRepository.getQueryResultsMock.mockResolvedValue(duneRepository.createResult([])) + + const service = new AffiliateStatsServiceImpl(duneRepository, 60_000) + const result = await service.getTraderActivity('0xabc') + + expect(result.rows).toEqual([]) + expect(result.lastUpdatedAt).toBe('2026-03-18T10:00:01.000Z') + }) + + it('uses cache for repeated trader activity requests', async () => { + const duneRepository = new MockDuneRepository() + duneRepository.getQueryResultsMock.mockResolvedValue(duneRepository.createResult([createTraderActivityRowRaw()])) + + const service = new AffiliateStatsServiceImpl(duneRepository, 60_000) + + await service.getTraderActivity('0xabc') + await service.getTraderActivity('0xAbC') + + expect(duneRepository.getQueryResultsMock).toHaveBeenCalledTimes(1) + expect(duneRepository.executeQueryMock).not.toHaveBeenCalled() + expect(duneRepository.waitForExecutionMock).not.toHaveBeenCalled() + }) + + it('throws for unsupported blockchains in trader activity rows', async () => { + const duneRepository = new MockDuneRepository() + duneRepository.getQueryResultsMock.mockResolvedValue( + duneRepository.createResult([createTraderActivityRowRaw({ blockchain: 'unknown-chain' })]) + ) + + const service = new AffiliateStatsServiceImpl(duneRepository, 60_000) + + await expect(service.getTraderActivity('0xabc')).rejects.toThrow( + 'Unsupported affiliate trader activity blockchain: unknown-chain' + ) + }) +}) diff --git a/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.ts b/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.ts index c6fe8660..1121ce7d 100644 --- a/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.ts +++ b/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.ts @@ -4,18 +4,30 @@ import { AffiliateStatsResult, AffiliateStatsRow, AffiliateStatsService, + TraderActivityDuneRow, + TraderActivityResult, + TraderActivityRow, TraderStatsResult, TraderStatsRow, } from './AffiliateStatsService' import { DUNE_MAX_ROWS, DUNE_PAGE_SIZE, getDuneQueryIds } from './AffiliateStatsService.config' -import type { AffiliateStatsRowRaw, CacheEntry, TraderStatsRowRaw } from './AffiliateStatsService.types' +import type { + AffiliateStatsRowRaw, + CacheEntry, + TraderActivityRowRaw, + TraderStatsRowRaw, +} from './AffiliateStatsService.types' import { isAffiliateStatsRowRaw, + isTraderActivityRowRaw, isTraderStatsRowRaw, normalizeAffiliateStatsRow, + normalizeTraderActivityRow, normalizeTraderStatsRow, } from './AffiliateStatsService.utils' +const DEFAULT_TRADER_ACTIVITY_LIMIT = 20 + export class AffiliateStatsServiceImpl implements AffiliateStatsService { private readonly duneRepository: DuneRepository private readonly cacheTtlMs: number @@ -40,6 +52,39 @@ export class AffiliateStatsServiceImpl implements AffiliateStatsService { return { rows: filtered, lastUpdatedAt } } + async getTraderActivity(address: string): Promise { + const normalizedAddress = address.toLowerCase() + const cacheKey = `affiliate-trader-activity:${normalizedAddress}:${DEFAULT_TRADER_ACTIVITY_LIMIT}` + const cached = this.getCache(cacheKey) + + if (cached) { + return { + rows: cached.rows as TraderActivityRow[], + lastUpdatedAt: cached.lastUpdatedAt, + } + } + + logger.debug(`Affiliate stats cache miss for ${cacheKey}.`) + + try { + const { rows: duneRows, lastUpdatedAt } = await this.getCachedQuery({ + cacheKey: 'affiliate-trader-activity', + queryId: getDuneQueryIds().traderActivity, + typeAssertion: isTraderActivityRowRaw, + mapRow: normalizeTraderActivityRow, + }) + const filteredRows = duneRows.filter((row) => row.trader_address.toLowerCase() === normalizedAddress) + const rows = filteredRows.slice(0, DEFAULT_TRADER_ACTIVITY_LIMIT) + + this.setCache(cacheKey, rows, lastUpdatedAt) + + return { rows, lastUpdatedAt } + } catch (error) { + logger.error({ error }, `Affiliate trader activity Dune query failed (${cacheKey}).`) + throw error + } + } + async getAffiliateStats(address: string): Promise { const normalizedAddress = address.toLowerCase() const { rows, lastUpdatedAt } = await this.getCachedQuery({ From a20ce812bf2d7b072bf318c86825889fa583ca9f Mon Sep 17 00:00:00 2001 From: Daniel Constantin Date: Tue, 5 May 2026 10:06:34 +0300 Subject: [PATCH 2/5] feat(affiliate): add sell and buy token symbols to trader activity --- .../trader-activity/_address/traderActivity.schemas.ts | 2 ++ .../src/AffiliateStatsService/AffiliateStatsService.ts | 4 ++++ .../AffiliateStatsService/AffiliateStatsService.types.ts | 7 ++++++- .../AffiliateStatsService/AffiliateStatsService.utils.ts | 4 ++++ .../AffiliateStatsServiceImpl.spec.ts | 4 ++++ 5 files changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/api/src/app/routes/affiliate/trader-activity/_address/traderActivity.schemas.ts b/apps/api/src/app/routes/affiliate/trader-activity/_address/traderActivity.schemas.ts index f510502e..05cc1d45 100644 --- a/apps/api/src/app/routes/affiliate/trader-activity/_address/traderActivity.schemas.ts +++ b/apps/api/src/app/routes/affiliate/trader-activity/_address/traderActivity.schemas.ts @@ -39,6 +39,8 @@ export const traderActivityRowSchema = { trader_address: { type: 'string' }, sell_token: { type: 'string' }, buy_token: { type: 'string' }, + sell_token_symbol: { type: 'string' }, + buy_token_symbol: { type: 'string' }, executed_sell_amount: { type: 'string' }, executed_buy_amount: { type: 'string' }, usd_value: { type: 'number' }, diff --git a/libs/services/src/AffiliateStatsService/AffiliateStatsService.ts b/libs/services/src/AffiliateStatsService/AffiliateStatsService.ts index df2bc374..a70af887 100644 --- a/libs/services/src/AffiliateStatsService/AffiliateStatsService.ts +++ b/libs/services/src/AffiliateStatsService/AffiliateStatsService.ts @@ -21,6 +21,8 @@ export interface TraderActivityDuneRow { trader_address: string sell_token: string buy_token: string + sell_token_symbol?: string + buy_token_symbol?: string executed_sell_amount: string executed_buy_amount: string usd_value: T @@ -40,6 +42,8 @@ export interface TraderActivityRow { trader_address: string sell_token: string buy_token: string + sell_token_symbol?: string + buy_token_symbol?: string executed_sell_amount: string executed_buy_amount: string usd_value: T diff --git a/libs/services/src/AffiliateStatsService/AffiliateStatsService.types.ts b/libs/services/src/AffiliateStatsService/AffiliateStatsService.types.ts index 77985d55..3fd9d3ba 100644 --- a/libs/services/src/AffiliateStatsService/AffiliateStatsService.types.ts +++ b/libs/services/src/AffiliateStatsService/AffiliateStatsService.types.ts @@ -10,8 +10,13 @@ export type NumericValue = number | string export type TraderStatsRowRaw = TraderStatsRow -export type TraderActivityRowRaw = Omit, 'chain_id'> & { +export type TraderActivityRowRaw = Omit< + TraderActivityDuneRow, + 'chain_id' | 'sell_token_symbol' | 'buy_token_symbol' +> & { blockchain: string + sell_token_symbol?: string | null + buy_token_symbol?: string | null } export type AffiliateStatsRowRaw = AffiliateStatsRow diff --git a/libs/services/src/AffiliateStatsService/AffiliateStatsService.utils.ts b/libs/services/src/AffiliateStatsService/AffiliateStatsService.utils.ts index 27d71d10..400bfcb4 100644 --- a/libs/services/src/AffiliateStatsService/AffiliateStatsService.utils.ts +++ b/libs/services/src/AffiliateStatsService/AffiliateStatsService.utils.ts @@ -52,6 +52,8 @@ export function isTraderActivityRowRaw(data: unknown): data is TraderActivityRow isString(data.trader_address) && isString(data.sell_token) && isString(data.buy_token) && + (data.sell_token_symbol == null || isString(data.sell_token_symbol)) && + (data.buy_token_symbol == null || isString(data.buy_token_symbol)) && isNumeric(data.executed_sell_amount) && isNumeric(data.executed_buy_amount) && isNumeric(data.usd_value) && @@ -107,6 +109,8 @@ export function normalizeTraderActivityRow(row: TraderActivityRowRaw): TraderAct trader_address: row.trader_address, sell_token: row.sell_token, buy_token: row.buy_token, + sell_token_symbol: row.sell_token_symbol || undefined, + buy_token_symbol: row.buy_token_symbol || undefined, executed_sell_amount: toDecimalString(row.executed_sell_amount, 'executed_sell_amount'), executed_buy_amount: toDecimalString(row.executed_buy_amount, 'executed_buy_amount'), usd_value: toNumber(row.usd_value, 'usd_value'), diff --git a/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.spec.ts b/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.spec.ts index 2707bc59..6892f570 100644 --- a/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.spec.ts +++ b/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.spec.ts @@ -90,6 +90,8 @@ function createTraderActivityRowRaw(overrides: Partial = { trader_address: '0xabc', sell_token: '0xsell-dune', buy_token: '0xbuy-dune', + sell_token_symbol: 'SELL', + buy_token_symbol: 'BUY', executed_sell_amount: '0.04667962868331909', executed_buy_amount: '0.00000123', usd_value: '123.45', @@ -146,6 +148,8 @@ describe('AffiliateStatsServiceImpl', () => { trader_address: row.trader_address, sell_token: row.sell_token, buy_token: row.buy_token, + sell_token_symbol: row.sell_token_symbol, + buy_token_symbol: row.buy_token_symbol, executed_sell_amount: '0.04667962868331909', executed_buy_amount: '0.00000123', usd_value: 123.45, From 3aeb4580489e17b9f363bd0bd6883524e5954b5c Mon Sep 17 00:00:00 2001 From: Daniel Constantin Date: Tue, 5 May 2026 16:05:43 +0300 Subject: [PATCH 3/5] feat(affiliate): update trader address handling and increase activity limit --- .../AffiliateStatsServiceImpl.spec.ts | 15 +++++++++------ .../AffiliateStatsServiceImpl.ts | 15 ++++++++------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.spec.ts b/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.spec.ts index 6892f570..ecb1a469 100644 --- a/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.spec.ts +++ b/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.spec.ts @@ -9,6 +9,9 @@ import { SupportedChainId } from '@cowprotocol/cow-sdk' import type { TraderActivityRowRaw } from './AffiliateStatsService.types' import { AffiliateStatsServiceImpl } from './AffiliateStatsServiceImpl' +const TRADER_ADDRESS = '0x0000000000000000000000000000000000000abc' +const TRADER_ADDRESS_CHECKSUM = '0x0000000000000000000000000000000000000AbC' + class MockDuneRepository implements DuneRepository { public readonly executeQueryMock = jest.fn< Promise, @@ -87,7 +90,7 @@ function createTraderActivityRowRaw(overrides: Partial = { creation_date: '2026-03-18 10:00:00.000 UTC', tx_hash: '0x123', order_uid: 'uid-1', - trader_address: '0xabc', + trader_address: TRADER_ADDRESS, sell_token: '0xsell-dune', buy_token: '0xbuy-dune', sell_token_symbol: 'SELL', @@ -128,7 +131,7 @@ describe('AffiliateStatsServiceImpl', () => { duneRepository.getQueryResultsMock.mockResolvedValue(duneRepository.createResult([row])) const service = new AffiliateStatsServiceImpl(duneRepository, 60_000) - const result = await service.getTraderActivity('0xAbC') + const result = await service.getTraderActivity(TRADER_ADDRESS_CHECKSUM) expect(duneRepository.getQueryResultsMock).toHaveBeenCalledWith({ queryId: 102, @@ -168,7 +171,7 @@ describe('AffiliateStatsServiceImpl', () => { duneRepository.getQueryResultsMock.mockResolvedValue(duneRepository.createResult([])) const service = new AffiliateStatsServiceImpl(duneRepository, 60_000) - const result = await service.getTraderActivity('0xabc') + const result = await service.getTraderActivity(TRADER_ADDRESS) expect(result.rows).toEqual([]) expect(result.lastUpdatedAt).toBe('2026-03-18T10:00:01.000Z') @@ -180,8 +183,8 @@ describe('AffiliateStatsServiceImpl', () => { const service = new AffiliateStatsServiceImpl(duneRepository, 60_000) - await service.getTraderActivity('0xabc') - await service.getTraderActivity('0xAbC') + await service.getTraderActivity(TRADER_ADDRESS) + await service.getTraderActivity(TRADER_ADDRESS_CHECKSUM) expect(duneRepository.getQueryResultsMock).toHaveBeenCalledTimes(1) expect(duneRepository.executeQueryMock).not.toHaveBeenCalled() @@ -196,7 +199,7 @@ describe('AffiliateStatsServiceImpl', () => { const service = new AffiliateStatsServiceImpl(duneRepository, 60_000) - await expect(service.getTraderActivity('0xabc')).rejects.toThrow( + await expect(service.getTraderActivity(TRADER_ADDRESS)).rejects.toThrow( 'Unsupported affiliate trader activity blockchain: unknown-chain' ) }) diff --git a/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.ts b/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.ts index 1121ce7d..76a9ab27 100644 --- a/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.ts +++ b/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.ts @@ -1,3 +1,4 @@ +import { getAddressKey } from '@cowprotocol/cow-sdk' import { logger } from '@cowprotocol/shared' import { DuneRepository } from '@cowprotocol/repositories' import { @@ -26,7 +27,7 @@ import { normalizeTraderStatsRow, } from './AffiliateStatsService.utils' -const DEFAULT_TRADER_ACTIVITY_LIMIT = 20 +const DEFAULT_TRADER_ACTIVITY_LIMIT = 50 export class AffiliateStatsServiceImpl implements AffiliateStatsService { private readonly duneRepository: DuneRepository @@ -39,7 +40,7 @@ export class AffiliateStatsServiceImpl implements AffiliateStatsService { } async getTraderStats(address: string): Promise { - const normalizedAddress = address.toLowerCase() + const normalizedAddress = getAddressKey(address) const { rows, lastUpdatedAt } = await this.getCachedQuery({ cacheKey: 'affiliate-trader-stats', queryId: getDuneQueryIds().traderStats, @@ -47,13 +48,13 @@ export class AffiliateStatsServiceImpl implements AffiliateStatsService { mapRow: normalizeTraderStatsRow, }) - const filtered = rows.filter((row) => row.trader_address.toLowerCase() === normalizedAddress) + const filtered = rows.filter((row) => getAddressKey(row.trader_address) === normalizedAddress) return { rows: filtered, lastUpdatedAt } } async getTraderActivity(address: string): Promise { - const normalizedAddress = address.toLowerCase() + const normalizedAddress = getAddressKey(address) const cacheKey = `affiliate-trader-activity:${normalizedAddress}:${DEFAULT_TRADER_ACTIVITY_LIMIT}` const cached = this.getCache(cacheKey) @@ -73,7 +74,7 @@ export class AffiliateStatsServiceImpl implements AffiliateStatsService { typeAssertion: isTraderActivityRowRaw, mapRow: normalizeTraderActivityRow, }) - const filteredRows = duneRows.filter((row) => row.trader_address.toLowerCase() === normalizedAddress) + const filteredRows = duneRows.filter((row) => getAddressKey(row.trader_address) === normalizedAddress) const rows = filteredRows.slice(0, DEFAULT_TRADER_ACTIVITY_LIMIT) this.setCache(cacheKey, rows, lastUpdatedAt) @@ -86,7 +87,7 @@ export class AffiliateStatsServiceImpl implements AffiliateStatsService { } async getAffiliateStats(address: string): Promise { - const normalizedAddress = address.toLowerCase() + const normalizedAddress = getAddressKey(address) const { rows, lastUpdatedAt } = await this.getCachedQuery({ cacheKey: 'affiliate-stats', queryId: getDuneQueryIds().affiliateStats, @@ -94,7 +95,7 @@ export class AffiliateStatsServiceImpl implements AffiliateStatsService { mapRow: normalizeAffiliateStatsRow, }) - const filtered = rows.filter((row) => row.affiliate_address.toLowerCase() === normalizedAddress) + const filtered = rows.filter((row) => getAddressKey(row.affiliate_address) === normalizedAddress) return { rows: filtered, lastUpdatedAt } } From 6fe97b3d76151fbdf1ece3fff62dc30207cace27 Mon Sep 17 00:00:00 2001 From: Daniel Constantin Date: Wed, 6 May 2026 11:23:49 +0300 Subject: [PATCH 4/5] refactor(affiliate): replace TraderActivityDuneRow with TraderActivityRow --- .../AffiliateStatsService.ts | 21 ------------------- .../AffiliateStatsService.types.ts | 4 ++-- .../AffiliateStatsService.utils.ts | 4 ++-- .../AffiliateStatsServiceImpl.ts | 5 ++--- 4 files changed, 6 insertions(+), 28 deletions(-) diff --git a/libs/services/src/AffiliateStatsService/AffiliateStatsService.ts b/libs/services/src/AffiliateStatsService/AffiliateStatsService.ts index a70af887..01afc458 100644 --- a/libs/services/src/AffiliateStatsService/AffiliateStatsService.ts +++ b/libs/services/src/AffiliateStatsService/AffiliateStatsService.ts @@ -13,27 +13,6 @@ export interface TraderStatsRow { next_payout: T } -export interface TraderActivityDuneRow { - chain_id: number - creation_date: string - tx_hash: string - order_uid: string - trader_address: string - sell_token: string - buy_token: string - sell_token_symbol?: string - buy_token_symbol?: string - executed_sell_amount: string - executed_buy_amount: string - usd_value: T - eligible_volume_usd: T - referrer_code: string - bound_referrer_code: string - eligibility_reason: string - is_bound_to_code: boolean - is_eligible: boolean -} - export interface TraderActivityRow { chain_id: number creation_date: string diff --git a/libs/services/src/AffiliateStatsService/AffiliateStatsService.types.ts b/libs/services/src/AffiliateStatsService/AffiliateStatsService.types.ts index 3fd9d3ba..dc94ccd4 100644 --- a/libs/services/src/AffiliateStatsService/AffiliateStatsService.types.ts +++ b/libs/services/src/AffiliateStatsService/AffiliateStatsService.types.ts @@ -1,4 +1,4 @@ -import type { AffiliateStatsRow, TraderActivityDuneRow, TraderStatsRow } from './AffiliateStatsService' +import type { AffiliateStatsRow, TraderActivityRow, TraderStatsRow } from './AffiliateStatsService' export interface CacheEntry { expiresAt: number @@ -11,7 +11,7 @@ export type NumericValue = number | string export type TraderStatsRowRaw = TraderStatsRow export type TraderActivityRowRaw = Omit< - TraderActivityDuneRow, + TraderActivityRow, 'chain_id' | 'sell_token_symbol' | 'buy_token_symbol' > & { blockchain: string diff --git a/libs/services/src/AffiliateStatsService/AffiliateStatsService.utils.ts b/libs/services/src/AffiliateStatsService/AffiliateStatsService.utils.ts index 400bfcb4..7f2bf62a 100644 --- a/libs/services/src/AffiliateStatsService/AffiliateStatsService.utils.ts +++ b/libs/services/src/AffiliateStatsService/AffiliateStatsService.utils.ts @@ -1,6 +1,6 @@ import { SupportedChainId } from '@cowprotocol/cow-sdk' import { BigNumber } from 'bignumber.js' -import type { AffiliateStatsRow, TraderActivityDuneRow, TraderStatsRow } from './AffiliateStatsService' +import type { AffiliateStatsRow, TraderActivityRow, TraderStatsRow } from './AffiliateStatsService' import type { AffiliateStatsRowRaw, TraderActivityRowRaw, TraderStatsRowRaw } from './AffiliateStatsService.types' import { isNumeric, isRecord, isString, toNumber } from '../utils/type-checking-utils' @@ -100,7 +100,7 @@ export function normalizeTraderStatsRow(row: TraderStatsRowRaw): TraderStatsRow } } -export function normalizeTraderActivityRow(row: TraderActivityRowRaw): TraderActivityDuneRow { +export function normalizeTraderActivityRow(row: TraderActivityRowRaw): TraderActivityRow { return { chain_id: getChainId(row.blockchain), creation_date: row.creation_date, diff --git a/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.ts b/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.ts index 76a9ab27..5b7cbdd5 100644 --- a/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.ts +++ b/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.ts @@ -5,7 +5,6 @@ import { AffiliateStatsResult, AffiliateStatsRow, AffiliateStatsService, - TraderActivityDuneRow, TraderActivityResult, TraderActivityRow, TraderStatsResult, @@ -60,7 +59,7 @@ export class AffiliateStatsServiceImpl implements AffiliateStatsService { if (cached) { return { - rows: cached.rows as TraderActivityRow[], + rows: cached.rows, lastUpdatedAt: cached.lastUpdatedAt, } } @@ -68,7 +67,7 @@ export class AffiliateStatsServiceImpl implements AffiliateStatsService { logger.debug(`Affiliate stats cache miss for ${cacheKey}.`) try { - const { rows: duneRows, lastUpdatedAt } = await this.getCachedQuery({ + const { rows: duneRows, lastUpdatedAt } = await this.getCachedQuery({ cacheKey: 'affiliate-trader-activity', queryId: getDuneQueryIds().traderActivity, typeAssertion: isTraderActivityRowRaw, From dafce8631d0b599d9d2f7d0f891303ce4239fa6d Mon Sep 17 00:00:00 2001 From: Daniel Constantin Date: Wed, 6 May 2026 15:39:14 +0300 Subject: [PATCH 5/5] refactor(affiliate): use address equality helper --- .../AffiliateStatsService/AffiliateStatsServiceImpl.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.ts b/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.ts index 5b7cbdd5..6c0bb577 100644 --- a/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.ts +++ b/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.ts @@ -1,4 +1,4 @@ -import { getAddressKey } from '@cowprotocol/cow-sdk' +import { areAddressesEqual, getAddressKey } from '@cowprotocol/cow-sdk' import { logger } from '@cowprotocol/shared' import { DuneRepository } from '@cowprotocol/repositories' import { @@ -47,7 +47,7 @@ export class AffiliateStatsServiceImpl implements AffiliateStatsService { mapRow: normalizeTraderStatsRow, }) - const filtered = rows.filter((row) => getAddressKey(row.trader_address) === normalizedAddress) + const filtered = rows.filter((row) => areAddressesEqual(row.trader_address, normalizedAddress)) return { rows: filtered, lastUpdatedAt } } @@ -73,7 +73,7 @@ export class AffiliateStatsServiceImpl implements AffiliateStatsService { typeAssertion: isTraderActivityRowRaw, mapRow: normalizeTraderActivityRow, }) - const filteredRows = duneRows.filter((row) => getAddressKey(row.trader_address) === normalizedAddress) + const filteredRows = duneRows.filter((row) => areAddressesEqual(row.trader_address, normalizedAddress)) const rows = filteredRows.slice(0, DEFAULT_TRADER_ACTIVITY_LIMIT) this.setCache(cacheKey, rows, lastUpdatedAt) @@ -94,7 +94,7 @@ export class AffiliateStatsServiceImpl implements AffiliateStatsService { mapRow: normalizeAffiliateStatsRow, }) - const filtered = rows.filter((row) => getAddressKey(row.affiliate_address) === normalizedAddress) + const filtered = rows.filter((row) => areAddressesEqual(row.affiliate_address, normalizedAddress)) return { rows: filtered, lastUpdatedAt } }