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..05cc1d45 --- /dev/null +++ b/apps/api/src/app/routes/affiliate/trader-activity/_address/traderActivity.schemas.ts @@ -0,0 +1,76 @@ +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' }, + sell_token_symbol: { type: 'string' }, + buy_token_symbol: { 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..01afc458 100644 --- a/libs/services/src/AffiliateStatsService/AffiliateStatsService.ts +++ b/libs/services/src/AffiliateStatsService/AffiliateStatsService.ts @@ -13,6 +13,27 @@ export interface TraderStatsRow { next_payout: T } +export interface TraderActivityRow { + 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 AffiliateStatsRow { affiliate_address: string referrer_code: string @@ -36,7 +57,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..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, TraderStatsRow } from './AffiliateStatsService' +import type { AffiliateStatsRow, TraderActivityRow, TraderStatsRow } from './AffiliateStatsService' export interface CacheEntry { expiresAt: number @@ -10,4 +10,13 @@ export type NumericValue = number | string export type TraderStatsRowRaw = TraderStatsRow +export type TraderActivityRowRaw = Omit< + TraderActivityRow, + '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 183fc9c3..7f2bf62a 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, TraderActivityRow, 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,33 @@ 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) && + (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) && + 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 +100,29 @@ export function normalizeTraderStatsRow(row: TraderStatsRowRaw): TraderStatsRow } } +export function normalizeTraderActivityRow(row: TraderActivityRowRaw): TraderActivityRow { + 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, + 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'), + 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 +137,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..ecb1a469 --- /dev/null +++ b/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.spec.ts @@ -0,0 +1,206 @@ +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' + +const TRADER_ADDRESS = '0x0000000000000000000000000000000000000abc' +const TRADER_ADDRESS_CHECKSUM = '0x0000000000000000000000000000000000000AbC' + +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: TRADER_ADDRESS, + 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', + 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(TRADER_ADDRESS_CHECKSUM) + + 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, + 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, + 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(TRADER_ADDRESS) + + 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(TRADER_ADDRESS) + await service.getTraderActivity(TRADER_ADDRESS_CHECKSUM) + + 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(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 c6fe8660..6c0bb577 100644 --- a/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.ts +++ b/libs/services/src/AffiliateStatsService/AffiliateStatsServiceImpl.ts @@ -1,21 +1,33 @@ +import { areAddressesEqual, getAddressKey } from '@cowprotocol/cow-sdk' import { logger } from '@cowprotocol/shared' import { DuneRepository } from '@cowprotocol/repositories' import { AffiliateStatsResult, AffiliateStatsRow, AffiliateStatsService, + 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 = 50 + export class AffiliateStatsServiceImpl implements AffiliateStatsService { private readonly duneRepository: DuneRepository private readonly cacheTtlMs: number @@ -27,7 +39,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, @@ -35,13 +47,46 @@ export class AffiliateStatsServiceImpl implements AffiliateStatsService { mapRow: normalizeTraderStatsRow, }) - const filtered = rows.filter((row) => row.trader_address.toLowerCase() === normalizedAddress) + const filtered = rows.filter((row) => areAddressesEqual(row.trader_address, normalizedAddress)) return { rows: filtered, lastUpdatedAt } } + async getTraderActivity(address: string): Promise { + const normalizedAddress = getAddressKey(address) + const cacheKey = `affiliate-trader-activity:${normalizedAddress}:${DEFAULT_TRADER_ACTIVITY_LIMIT}` + const cached = this.getCache(cacheKey) + + if (cached) { + return { + rows: cached.rows, + 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) => areAddressesEqual(row.trader_address, 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 normalizedAddress = getAddressKey(address) const { rows, lastUpdatedAt } = await this.getCachedQuery({ cacheKey: 'affiliate-stats', queryId: getDuneQueryIds().affiliateStats, @@ -49,7 +94,7 @@ export class AffiliateStatsServiceImpl implements AffiliateStatsService { mapRow: normalizeAffiliateStatsRow, }) - const filtered = rows.filter((row) => row.affiliate_address.toLowerCase() === normalizedAddress) + const filtered = rows.filter((row) => areAddressesEqual(row.affiliate_address, normalizedAddress)) return { rows: filtered, lastUpdatedAt } }