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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof paramsSchema>
type ResponseSchema = FromSchema<typeof responseSchema> | FromSchema<typeof errorSchema>

const traderActivity: FastifyPluginAsync = async (fastify): Promise<void> => {
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<AffiliateStatsService>(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
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions apps/api/src/app/routes/ref-codes/_code/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export function getDuneQueryIds(): {
traderStats: number
traderActivity: number
affiliateStats: number
} {
const traderRaw = process.env.DUNE_QUERY_ID_TRADER_STATS
Expand All @@ -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')
Expand All @@ -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
Expand Down
27 changes: 27 additions & 0 deletions libs/services/src/AffiliateStatsService/AffiliateStatsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,27 @@ export interface TraderStatsRow<T = number> {
next_payout: T
}

export interface TraderActivityRow<T = number> {
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<T = number> {
affiliate_address: string
referrer_code: string
Expand All @@ -36,7 +57,13 @@ export interface TraderStatsResult {
lastUpdatedAt: string
}

export interface TraderActivityResult {
rows: TraderActivityRow[]
lastUpdatedAt: string
}

export interface AffiliateStatsService {
getTraderStats(address: string): Promise<TraderStatsResult>
getTraderActivity(address: string): Promise<TraderActivityResult>
getAffiliateStats(address: string): Promise<AffiliateStatsResult>
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AffiliateStatsRow, TraderStatsRow } from './AffiliateStatsService'
import type { AffiliateStatsRow, TraderActivityRow, TraderStatsRow } from './AffiliateStatsService'

export interface CacheEntry<T> {
expiresAt: number
Expand All @@ -10,4 +10,13 @@ export type NumericValue = number | string

export type TraderStatsRowRaw = TraderStatsRow<NumericValue>

export type TraderActivityRowRaw = Omit<
TraderActivityRow<NumericValue>,
'chain_id' | 'sell_token_symbol' | 'buy_token_symbol'
> & {
blockchain: string
sell_token_symbol?: string | null
buy_token_symbol?: string | null
}

export type AffiliateStatsRowRaw = AffiliateStatsRow<NumericValue>
Original file line number Diff line number Diff line change
@@ -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<string, SupportedChainId> = {
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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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()
}
Loading
Loading