From 8bbbee44b48a6b606cfbed68e8507ad897d532c4 Mon Sep 17 00:00:00 2001 From: Jibles Date: Fri, 19 Dec 2025 14:46:12 +0530 Subject: [PATCH 1/2] feat: fix portals and near intents affiliate tracker --- node/proxy/api/src/affiliateRevenue/bebop.ts | 78 ----- .../api/src/affiliateRevenue/bebop/bebop.ts | 37 +++ .../src/affiliateRevenue/bebop/constants.ts | 8 + .../api/src/affiliateRevenue/bebop/index.ts | 1 + .../api/src/affiliateRevenue/bebop/types.ts | 37 +++ .../{ => butterswap}/butterswap.ts | 90 ++---- .../affiliateRevenue/butterswap/constants.ts | 25 ++ .../src/affiliateRevenue/butterswap/index.ts | 1 + .../src/affiliateRevenue/butterswap/types.ts | 15 + .../src/affiliateRevenue/butterswap/utils.ts | 33 +++ .../api/src/affiliateRevenue/chainflip.ts | 106 ------- .../affiliateRevenue/chainflip/chainflip.ts | 51 ++++ .../affiliateRevenue/chainflip/constants.ts | 38 +++ .../src/affiliateRevenue/chainflip/index.ts | 1 + .../src/affiliateRevenue/chainflip/types.ts | 17 ++ .../api/src/affiliateRevenue/constants.ts | 3 + .../api/src/affiliateRevenue/mayachain.ts | 59 ---- .../affiliateRevenue/mayachain/constants.ts | 4 + .../src/affiliateRevenue/mayachain/index.ts | 1 + .../affiliateRevenue/mayachain/mayachain.ts | 46 +++ .../src/affiliateRevenue/mayachain/types.ts | 11 + .../api/src/affiliateRevenue/nearIntents.ts | 157 ---------- .../affiliateRevenue/nearIntents/constants.ts | 41 +++ .../src/affiliateRevenue/nearIntents/index.ts | 1 + .../nearIntents/nearIntents.ts | 79 +++++ .../src/affiliateRevenue/nearIntents/types.ts | 35 +++ .../src/affiliateRevenue/nearIntents/utils.ts | 55 ++++ .../proxy/api/src/affiliateRevenue/portals.ts | 118 -------- .../src/affiliateRevenue/portals/constants.ts | 103 +++++++ .../api/src/affiliateRevenue/portals/index.ts | 1 + .../src/affiliateRevenue/portals/portals.ts | 269 ++++++++++++++++++ .../api/src/affiliateRevenue/portals/types.ts | 95 +++++++ .../api/src/affiliateRevenue/portals/utils.ts | 87 ++++++ node/proxy/api/src/affiliateRevenue/relay.ts | 178 ------------ .../src/affiliateRevenue/relay/constants.ts | 12 + .../api/src/affiliateRevenue/relay/index.ts | 1 + .../api/src/affiliateRevenue/relay/relay.ts | 59 ++++ .../api/src/affiliateRevenue/relay/types.ts | 48 ++++ .../api/src/affiliateRevenue/relay/utils.ts | 48 ++++ .../affiliateRevenue/thorchain/constants.ts | 4 + .../src/affiliateRevenue/thorchain/index.ts | 1 + .../{ => thorchain}/thorchain.ts | 41 +-- .../src/affiliateRevenue/thorchain/types.ts | 11 + .../api/src/affiliateRevenue/zrx/constants.ts | 10 + .../api/src/affiliateRevenue/zrx/index.ts | 1 + .../api/src/affiliateRevenue/zrx/types.ts | 37 +++ .../api/src/affiliateRevenue/{ => zrx}/zrx.ts | 52 +--- node/proxy/sample.env | 2 +- 48 files changed, 1366 insertions(+), 842 deletions(-) delete mode 100644 node/proxy/api/src/affiliateRevenue/bebop.ts create mode 100644 node/proxy/api/src/affiliateRevenue/bebop/bebop.ts create mode 100644 node/proxy/api/src/affiliateRevenue/bebop/constants.ts create mode 100644 node/proxy/api/src/affiliateRevenue/bebop/index.ts create mode 100644 node/proxy/api/src/affiliateRevenue/bebop/types.ts rename node/proxy/api/src/affiliateRevenue/{ => butterswap}/butterswap.ts (56%) create mode 100644 node/proxy/api/src/affiliateRevenue/butterswap/constants.ts create mode 100644 node/proxy/api/src/affiliateRevenue/butterswap/index.ts create mode 100644 node/proxy/api/src/affiliateRevenue/butterswap/types.ts create mode 100644 node/proxy/api/src/affiliateRevenue/butterswap/utils.ts delete mode 100644 node/proxy/api/src/affiliateRevenue/chainflip.ts create mode 100644 node/proxy/api/src/affiliateRevenue/chainflip/chainflip.ts create mode 100644 node/proxy/api/src/affiliateRevenue/chainflip/constants.ts create mode 100644 node/proxy/api/src/affiliateRevenue/chainflip/index.ts create mode 100644 node/proxy/api/src/affiliateRevenue/chainflip/types.ts delete mode 100644 node/proxy/api/src/affiliateRevenue/mayachain.ts create mode 100644 node/proxy/api/src/affiliateRevenue/mayachain/constants.ts create mode 100644 node/proxy/api/src/affiliateRevenue/mayachain/index.ts create mode 100644 node/proxy/api/src/affiliateRevenue/mayachain/mayachain.ts create mode 100644 node/proxy/api/src/affiliateRevenue/mayachain/types.ts delete mode 100644 node/proxy/api/src/affiliateRevenue/nearIntents.ts create mode 100644 node/proxy/api/src/affiliateRevenue/nearIntents/constants.ts create mode 100644 node/proxy/api/src/affiliateRevenue/nearIntents/index.ts create mode 100644 node/proxy/api/src/affiliateRevenue/nearIntents/nearIntents.ts create mode 100644 node/proxy/api/src/affiliateRevenue/nearIntents/types.ts create mode 100644 node/proxy/api/src/affiliateRevenue/nearIntents/utils.ts delete mode 100644 node/proxy/api/src/affiliateRevenue/portals.ts create mode 100644 node/proxy/api/src/affiliateRevenue/portals/constants.ts create mode 100644 node/proxy/api/src/affiliateRevenue/portals/index.ts create mode 100644 node/proxy/api/src/affiliateRevenue/portals/portals.ts create mode 100644 node/proxy/api/src/affiliateRevenue/portals/types.ts create mode 100644 node/proxy/api/src/affiliateRevenue/portals/utils.ts delete mode 100644 node/proxy/api/src/affiliateRevenue/relay.ts create mode 100644 node/proxy/api/src/affiliateRevenue/relay/constants.ts create mode 100644 node/proxy/api/src/affiliateRevenue/relay/index.ts create mode 100644 node/proxy/api/src/affiliateRevenue/relay/relay.ts create mode 100644 node/proxy/api/src/affiliateRevenue/relay/types.ts create mode 100644 node/proxy/api/src/affiliateRevenue/relay/utils.ts create mode 100644 node/proxy/api/src/affiliateRevenue/thorchain/constants.ts create mode 100644 node/proxy/api/src/affiliateRevenue/thorchain/index.ts rename node/proxy/api/src/affiliateRevenue/{ => thorchain}/thorchain.ts (50%) create mode 100644 node/proxy/api/src/affiliateRevenue/thorchain/types.ts create mode 100644 node/proxy/api/src/affiliateRevenue/zrx/constants.ts create mode 100644 node/proxy/api/src/affiliateRevenue/zrx/index.ts create mode 100644 node/proxy/api/src/affiliateRevenue/zrx/types.ts rename node/proxy/api/src/affiliateRevenue/{ => zrx}/zrx.ts (51%) diff --git a/node/proxy/api/src/affiliateRevenue/bebop.ts b/node/proxy/api/src/affiliateRevenue/bebop.ts deleted file mode 100644 index dff26b9db..000000000 --- a/node/proxy/api/src/affiliateRevenue/bebop.ts +++ /dev/null @@ -1,78 +0,0 @@ -import axios from 'axios' -import { Fees } from '.' -import { SLIP44 } from './constants' - -const BEBOP_API_KEY = process.env.BEBOP_API_KEY - -if (!BEBOP_API_KEY) throw new Error('BEBOP_API_KEY env var not set') - -type TradesResponse = { - results: Array<{ - chain_id: number - txHash: string - status: string - type: string - taker: string - receiver: string - sellTokens: Record - buyTokens: Record - volumeUsd?: number - gasFeeUsd?: number - timestamp: string - route: 'JAM' | 'PMM' - gasless: boolean - partnerFeeNative?: string - partnerFeeBps?: string - }> - nextAvailableTimestamp?: string - metadata: { - timestamp: string - results?: number - tokens: Record< - string, - Record< - string, - { - name: string - symbol: string - decimals: number - displayDecimals?: number - icon?: string - } - > - > - } -} - -// https://docs.bebop.xyz/bebop/trade-history-api/history-api-endpoints/all-trades -export const getFees = async (startTimestamp: number, endTimestamp: number): Promise> => { - const fees: Array = [] - - const start = startTimestamp * 1_000_000_000 // nanoseconds - const end = endTimestamp * 1_000_000_000 // nanoseconds - - const { data } = await axios.get('https://api.bebop.xyz/history/v2/trades', { - params: { source: 'shapeshift', start, end }, - headers: { 'source-auth': BEBOP_API_KEY }, - }) - - for (const trade of data.results) { - if (!trade.partnerFeeBps || !trade.partnerFeeNative) continue - - const chainId = `eip155:${trade.chain_id}` - const assetId = `${chainId}/slip44:${SLIP44.ETHEREUM}` - - fees.push({ - chainId, - assetId, - service: 'bebop', - txHash: trade.txHash, - timestamp: Math.floor(new Date(trade.timestamp).getTime() / 1000), - amount: trade.partnerFeeNative, - amountUsd: - trade.volumeUsd !== undefined ? String(trade.volumeUsd * (Number(trade.partnerFeeBps) / 10000)) : undefined, - }) - } - - return fees -} diff --git a/node/proxy/api/src/affiliateRevenue/bebop/bebop.ts b/node/proxy/api/src/affiliateRevenue/bebop/bebop.ts new file mode 100644 index 000000000..9a6142c10 --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/bebop/bebop.ts @@ -0,0 +1,37 @@ +import axios from 'axios' +import { Fees } from '..' +import { SLIP44 } from '../constants' +import { BEBOP_API_KEY, BEBOP_API_URL, FEE_BPS_DENOMINATOR, NANOSECONDS_PER_SECOND, SHAPESHIFT_REFERRER } from './constants' +import type { TradesResponse } from './types' + +export const getFees = async (startTimestamp: number, endTimestamp: number): Promise> => { + const fees: Array = [] + + const start = startTimestamp * NANOSECONDS_PER_SECOND + const end = endTimestamp * NANOSECONDS_PER_SECOND + + const { data } = await axios.get(BEBOP_API_URL, { + params: { source: SHAPESHIFT_REFERRER, start, end }, + headers: { 'source-auth': BEBOP_API_KEY }, + }) + + for (const trade of data.results) { + if (!trade.partnerFeeBps || !trade.partnerFeeNative) continue + + const chainId = `eip155:${trade.chain_id}` + const assetId = `${chainId}/slip44:${SLIP44.ETHEREUM}` + + fees.push({ + chainId, + assetId, + service: 'bebop', + txHash: trade.txHash, + timestamp: Math.floor(new Date(trade.timestamp).getTime() / 1000), + amount: trade.partnerFeeNative, + amountUsd: + trade.volumeUsd !== undefined ? String(trade.volumeUsd * (Number(trade.partnerFeeBps) / FEE_BPS_DENOMINATOR)) : undefined, + }) + } + + return fees +} diff --git a/node/proxy/api/src/affiliateRevenue/bebop/constants.ts b/node/proxy/api/src/affiliateRevenue/bebop/constants.ts new file mode 100644 index 000000000..fb04f1c93 --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/bebop/constants.ts @@ -0,0 +1,8 @@ +export const BEBOP_API_KEY = process.env.BEBOP_API_KEY + +if (!BEBOP_API_KEY) throw new Error('BEBOP_API_KEY env var not set') + +export const BEBOP_API_URL = 'https://api.bebop.xyz/history/v2/trades' +export const SHAPESHIFT_REFERRER = 'shapeshift' +export const NANOSECONDS_PER_SECOND = 1_000_000_000 +export const FEE_BPS_DENOMINATOR = 10000 diff --git a/node/proxy/api/src/affiliateRevenue/bebop/index.ts b/node/proxy/api/src/affiliateRevenue/bebop/index.ts new file mode 100644 index 000000000..4db1b66ee --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/bebop/index.ts @@ -0,0 +1 @@ +export { getFees } from './bebop' diff --git a/node/proxy/api/src/affiliateRevenue/bebop/types.ts b/node/proxy/api/src/affiliateRevenue/bebop/types.ts new file mode 100644 index 000000000..100bf4eaa --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/bebop/types.ts @@ -0,0 +1,37 @@ +export type TradesResponse = { + results: Array<{ + chain_id: number + txHash: string + status: string + type: string + taker: string + receiver: string + sellTokens: Record + buyTokens: Record + volumeUsd?: number + gasFeeUsd?: number + timestamp: string + route: 'JAM' | 'PMM' + gasless: boolean + partnerFeeNative?: string + partnerFeeBps?: string + }> + nextAvailableTimestamp?: string + metadata: { + timestamp: string + results?: number + tokens: Record< + string, + Record< + string, + { + name: string + symbol: string + decimals: number + displayDecimals?: number + icon?: string + } + > + > + } +} diff --git a/node/proxy/api/src/affiliateRevenue/butterswap.ts b/node/proxy/api/src/affiliateRevenue/butterswap/butterswap.ts similarity index 56% rename from node/proxy/api/src/affiliateRevenue/butterswap.ts rename to node/proxy/api/src/affiliateRevenue/butterswap/butterswap.ts index 6c25cf47a..d11d30930 100644 --- a/node/proxy/api/src/affiliateRevenue/butterswap.ts +++ b/node/proxy/api/src/affiliateRevenue/butterswap/butterswap.ts @@ -1,45 +1,22 @@ import { encodeAbiParameters, parseAbiParameters } from 'viem' -import { Fees } from '.' -import { BUTTERSWAP_AFFILIATE_ID, BUTTERSWAP_CONTRACT, MAP_CHAIN_ID, MAP_RPC_URL, MAP_USDT_ADDRESS } from './constants' - -const BLOCK_TIME_SECONDS = 5 -const USDT_DECIMALS = 18 -const TOKEN_LIST_API = 'https://butterapi.chainservice.io/api/token/bam/list' -const TOKEN_CACHE_TTL_MS = 60 * 60 * 1000 // 1 hour - -const GET_TOTAL_BALANCE_SELECTOR = '0x47b2f8d9' -const API_SUCCESS_CODE = 0 -const HEX_RADIX = 16 -const HEX_PREFIX_LENGTH = 2 -const UINT256_HEX_LENGTH = 66 // 0x prefix (2) + 64 hex chars - -type RpcResponse = { - jsonrpc: string - id: number - result: T - error?: { code: number; message: string } -} - -type TokenListResponse = { - errno: number - message: string - data: { - items: Array<{ address: string; symbol: string }> - total: number - } -} - -// Fallback token list in case API is unavailable -const FALLBACK_TOKENS = [ - '0x05ab928d446d8ce6761e368c8e7be03c3168a9ec', // ETH - '0x33daba9618a75a7aff103e53afe530fbacf4a3dd', // USDT - '0x9f722b2cb30093f766221fd0d37964949ed66918', // USDC - '0xb877e3562a660c7861117c2f1361a26abaf19beb', // BTC - '0x5de6606ae1250c64560a603b40078de268240fdd', // SOL - '0xc478a25240d9c072ebec5109b417e0a78a41667c', // BNB - '0x593a37fe0f6dfd0b6c5a051e9a44aa0f6922a1a2', // TRX - '0x0e9e7317c7132604c009c9860a259a3da33a3ed3', // TONCOIN -] +import { Fees } from '..' +import { + API_SUCCESS_CODE, + BUTTERSWAP_AFFILIATE_ID, + BUTTERSWAP_CONTRACT, + FALLBACK_TOKENS, + GET_TOTAL_BALANCE_SELECTOR, + HEX_PREFIX_LENGTH, + HEX_RADIX, + MAP_CHAIN_ID, + MAP_USDT_ADDRESS, + TOKEN_CACHE_TTL_MS, + TOKEN_LIST_API, + UINT256_HEX_LENGTH, + USDT_DECIMALS, +} from './constants' +import type { TokenListResponse } from './types' +import { estimateBlockFromTimestamp, rpcCall } from './utils' let cachedTokens: string[] | null = null let tokensCachedAt = 0 @@ -66,27 +43,6 @@ const fetchTokenList = async (): Promise => { return FALLBACK_TOKENS } -const rpcCall = async (method: string, params: unknown[]): Promise => { - const response = await fetch(MAP_RPC_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method, - params, - }), - }) - - const data: RpcResponse = await response.json() - - if (data.error) { - throw new Error(`RPC error: ${data.error.message}`) - } - - return data.result -} - const getBlockNumber = async (): Promise => { const result = await rpcCall('eth_blockNumber', []) return parseInt(result, HEX_RADIX) @@ -107,16 +63,6 @@ const getTotalBalance = async (blockNumber: number, tokens: string[]): Promise { - const blocksAgo = Math.floor((currentTimestamp - targetTimestamp) / BLOCK_TIME_SECONDS) - const estimatedBlock = currentBlock - blocksAgo - return Math.max(0, Math.min(estimatedBlock, currentBlock)) -} - export const getFees = async (startTimestamp: number, endTimestamp: number): Promise> => { const tokens = await fetchTokenList() diff --git a/node/proxy/api/src/affiliateRevenue/butterswap/constants.ts b/node/proxy/api/src/affiliateRevenue/butterswap/constants.ts new file mode 100644 index 000000000..972cb9a1d --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/butterswap/constants.ts @@ -0,0 +1,25 @@ +import { BUTTERSWAP_AFFILIATE_ID, BUTTERSWAP_CONTRACT, MAP_CHAIN_ID, MAP_RPC_URL, MAP_USDT_ADDRESS } from '../constants' + +export { BUTTERSWAP_AFFILIATE_ID, BUTTERSWAP_CONTRACT, MAP_CHAIN_ID, MAP_RPC_URL, MAP_USDT_ADDRESS } + +export const BLOCK_TIME_SECONDS = 5 +export const USDT_DECIMALS = 18 +export const TOKEN_LIST_API = 'https://butterapi.chainservice.io/api/token/bam/list' +export const TOKEN_CACHE_TTL_MS = 60 * 60 * 1000 + +export const GET_TOTAL_BALANCE_SELECTOR = '0x47b2f8d9' +export const API_SUCCESS_CODE = 0 +export const HEX_RADIX = 16 +export const HEX_PREFIX_LENGTH = 2 +export const UINT256_HEX_LENGTH = 66 + +export const FALLBACK_TOKENS = [ + '0x05ab928d446d8ce6761e368c8e7be03c3168a9ec', + '0x33daba9618a75a7aff103e53afe530fbacf4a3dd', + '0x9f722b2cb30093f766221fd0d37964949ed66918', + '0xb877e3562a660c7861117c2f1361a26abaf19beb', + '0x5de6606ae1250c64560a603b40078de268240fdd', + '0xc478a25240d9c072ebec5109b417e0a78a41667c', + '0x593a37fe0f6dfd0b6c5a051e9a44aa0f6922a1a2', + '0x0e9e7317c7132604c009c9860a259a3da33a3ed3', +] diff --git a/node/proxy/api/src/affiliateRevenue/butterswap/index.ts b/node/proxy/api/src/affiliateRevenue/butterswap/index.ts new file mode 100644 index 000000000..5a7154a73 --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/butterswap/index.ts @@ -0,0 +1 @@ +export { getFees } from './butterswap' diff --git a/node/proxy/api/src/affiliateRevenue/butterswap/types.ts b/node/proxy/api/src/affiliateRevenue/butterswap/types.ts new file mode 100644 index 000000000..491d1eabf --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/butterswap/types.ts @@ -0,0 +1,15 @@ +export type RpcResponse = { + jsonrpc: string + id: number + result: T + error?: { code: number; message: string } +} + +export type TokenListResponse = { + errno: number + message: string + data: { + items: Array<{ address: string; symbol: string }> + total: number + } +} diff --git a/node/proxy/api/src/affiliateRevenue/butterswap/utils.ts b/node/proxy/api/src/affiliateRevenue/butterswap/utils.ts new file mode 100644 index 000000000..f8b4507d9 --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/butterswap/utils.ts @@ -0,0 +1,33 @@ +import { BLOCK_TIME_SECONDS, MAP_RPC_URL } from './constants' +import type { RpcResponse } from './types' + +export const rpcCall = async (method: string, params: unknown[]): Promise => { + const response = await fetch(MAP_RPC_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method, + params, + }), + }) + + const data: RpcResponse = await response.json() + + if (data.error) { + throw new Error(`RPC error: ${data.error.message}`) + } + + return data.result +} + +export const estimateBlockFromTimestamp = ( + currentBlock: number, + currentTimestamp: number, + targetTimestamp: number +): number => { + const blocksAgo = Math.floor((currentTimestamp - targetTimestamp) / BLOCK_TIME_SECONDS) + const estimatedBlock = currentBlock - blocksAgo + return Math.max(0, Math.min(estimatedBlock, currentBlock)) +} diff --git a/node/proxy/api/src/affiliateRevenue/chainflip.ts b/node/proxy/api/src/affiliateRevenue/chainflip.ts deleted file mode 100644 index c9936d357..000000000 --- a/node/proxy/api/src/affiliateRevenue/chainflip.ts +++ /dev/null @@ -1,106 +0,0 @@ -import axios from 'axios' -import { Fees } from '.' - -const pageSize = 100 -const affiliateBrokerId = 'cFMeDPtPHccVYdBSJKTtCYuy7rewFNpro3xZBKaCGbSS2xhRi' - -const GET_AFFILIATE_SWAPS_QUERY = ` - query GetAffiliateSwaps( - $affiliateBrokerId: String! - $startDate: Datetime! - $endDate: Datetime! - $first: Int! - $offset: Int! - ) { - allSwapRequests( - offset: $offset - first: $first - filter: { - affiliateBroker1AccountSs58Id: {equalTo: $affiliateBrokerId} - completedBlockTimestamp: { - greaterThanOrEqualTo: $startDate - lessThanOrEqualTo: $endDate - } - status: {equalTo: SUCCESS} - } - ) { - pageInfo { - hasNextPage - } - edges { - node { - swapRequestNativeId - completedBlockTimestamp - affiliateBroker1FeeValueUsd - } - } - totalCount - } - } -` - -type GraphQLResponse = { - data: { - allSwapRequests: { - pageInfo: { - hasNextPage: boolean - } - edges: Array<{ - node: { - swapRequestNativeId: string - completedBlockTimestamp: string - affiliateBroker1FeeValueUsd?: string - } - }> - totalCount: number - } - } -} - -export const getFees = async (startTimestamp: number, endTimestamp: number): Promise> => { - const fees: Array = [] - - const startDate = new Date(startTimestamp * 1000).toISOString() - const endDate = new Date(endTimestamp * 1000).toISOString() - - let offset = 0 - let hasNextPage = true - do { - const { data } = await axios.post('https://reporting-service.chainflip.io/graphql', { - query: GET_AFFILIATE_SWAPS_QUERY, - variables: { - affiliateBrokerId, - startDate, - endDate, - first: pageSize, - offset, - }, - operationName: 'GetAffiliateSwaps', - }) - - const { edges, pageInfo } = data.data.allSwapRequests - - for (const { node: swap } of edges) { - if (!swap.affiliateBroker1FeeValueUsd) continue - - // Chainflip affiliate fees are always paid in USDC - const chainId = 'eip155:1' - const assetId = `${chainId}/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48` - - fees.push({ - chainId, - assetId, - service: 'chainflip', - txHash: '', // requires additional GetSwapByNativeId query - timestamp: Math.floor(new Date(swap.completedBlockTimestamp).getTime() / 1000), - amount: '0', // requires additional GetSwapByNativeId query - amountUsd: swap.affiliateBroker1FeeValueUsd, - }) - } - - hasNextPage = pageInfo.hasNextPage - offset += pageSize - } while (hasNextPage) - - return fees -} diff --git a/node/proxy/api/src/affiliateRevenue/chainflip/chainflip.ts b/node/proxy/api/src/affiliateRevenue/chainflip/chainflip.ts new file mode 100644 index 000000000..31b94df16 --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/chainflip/chainflip.ts @@ -0,0 +1,51 @@ +import axios from 'axios' +import { Fees } from '..' +import { CHAINFLIP_API_URL, GET_AFFILIATE_SWAPS_QUERY, PAGE_SIZE, SHAPESHIFT_BROKER_ID } from './constants' +import type { GraphQLResponse } from './types' + +export const getFees = async (startTimestamp: number, endTimestamp: number): Promise> => { + const fees: Array = [] + + const startDate = new Date(startTimestamp * 1000).toISOString() + const endDate = new Date(endTimestamp * 1000).toISOString() + + let offset = 0 + let hasNextPage = true + do { + const { data } = await axios.post(CHAINFLIP_API_URL, { + query: GET_AFFILIATE_SWAPS_QUERY, + variables: { + affiliateBrokerId: SHAPESHIFT_BROKER_ID, + startDate, + endDate, + first: PAGE_SIZE, + offset, + }, + operationName: 'GetAffiliateSwaps', + }) + + const { edges, pageInfo } = data.data.allSwapRequests + + for (const { node: swap } of edges) { + if (!swap.affiliateBroker1FeeValueUsd) continue + + const chainId = 'eip155:1' + const assetId = `${chainId}/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48` + + fees.push({ + chainId, + assetId, + service: 'chainflip', + txHash: '', + timestamp: Math.floor(new Date(swap.completedBlockTimestamp).getTime() / 1000), + amount: '0', + amountUsd: swap.affiliateBroker1FeeValueUsd, + }) + } + + hasNextPage = pageInfo.hasNextPage + offset += PAGE_SIZE + } while (hasNextPage) + + return fees +} diff --git a/node/proxy/api/src/affiliateRevenue/chainflip/constants.ts b/node/proxy/api/src/affiliateRevenue/chainflip/constants.ts new file mode 100644 index 000000000..c7369d718 --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/chainflip/constants.ts @@ -0,0 +1,38 @@ +export const CHAINFLIP_API_URL = 'https://reporting-service.chainflip.io/graphql' +export const PAGE_SIZE = 100 +export const SHAPESHIFT_BROKER_ID = 'cFMeDPtPHccVYdBSJKTtCYuy7rewFNpro3xZBKaCGbSS2xhRi' + +export const GET_AFFILIATE_SWAPS_QUERY = ` + query GetAffiliateSwaps( + $affiliateBrokerId: String! + $startDate: Datetime! + $endDate: Datetime! + $first: Int! + $offset: Int! + ) { + allSwapRequests( + offset: $offset + first: $first + filter: { + affiliateBroker1AccountSs58Id: {equalTo: $affiliateBrokerId} + completedBlockTimestamp: { + greaterThanOrEqualTo: $startDate + lessThanOrEqualTo: $endDate + } + status: {equalTo: SUCCESS} + } + ) { + pageInfo { + hasNextPage + } + edges { + node { + swapRequestNativeId + completedBlockTimestamp + affiliateBroker1FeeValueUsd + } + } + totalCount + } + } +` diff --git a/node/proxy/api/src/affiliateRevenue/chainflip/index.ts b/node/proxy/api/src/affiliateRevenue/chainflip/index.ts new file mode 100644 index 000000000..e3d894267 --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/chainflip/index.ts @@ -0,0 +1 @@ +export { getFees } from './chainflip' diff --git a/node/proxy/api/src/affiliateRevenue/chainflip/types.ts b/node/proxy/api/src/affiliateRevenue/chainflip/types.ts new file mode 100644 index 000000000..a7c4c65b1 --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/chainflip/types.ts @@ -0,0 +1,17 @@ +export type GraphQLResponse = { + data: { + allSwapRequests: { + pageInfo: { + hasNextPage: boolean + } + edges: Array<{ + node: { + swapRequestNativeId: string + completedBlockTimestamp: string + affiliateBroker1FeeValueUsd?: string + } + }> + totalCount: number + } + } +} diff --git a/node/proxy/api/src/affiliateRevenue/constants.ts b/node/proxy/api/src/affiliateRevenue/constants.ts index 43150776d..ac731d545 100644 --- a/node/proxy/api/src/affiliateRevenue/constants.ts +++ b/node/proxy/api/src/affiliateRevenue/constants.ts @@ -37,3 +37,6 @@ export const SLIP44 = { THORCHAIN: 931, MAYACHAIN: 931, } as const + +// Portals.fi - PortalsMulticall sends fee tokens to treasury after each swap +export const PORTALS_MULTICALL = '0x89c30E3Af15D210736b2918fbD655c9842Fd74f7' diff --git a/node/proxy/api/src/affiliateRevenue/mayachain.ts b/node/proxy/api/src/affiliateRevenue/mayachain.ts deleted file mode 100644 index 42a287c23..000000000 --- a/node/proxy/api/src/affiliateRevenue/mayachain.ts +++ /dev/null @@ -1,59 +0,0 @@ -import axios from 'axios' -import { Fees } from '.' -import { MAYACHAIN_CHAIN_ID, SLIP44 } from './constants' - -type FeesResponse = { - fees: Array<{ - address: string - amount: string - asset: string - blockHash: string - blockHeight: number - timestamp: number - txId: string - }> -} - -const getCacaoPriceUsd = async (): Promise => { - const { data } = await axios.get<{ cacao: { usd: string } }>( - 'https://api.proxy.shapeshift.com/api/v1/markets/simple/price', - { - params: { - vs_currencies: 'usd', - ids: 'cacao', - }, - } - ) - - return Number(data.cacao.usd) -} - -export const getFees = async (startTimestamp: number, endTimestamp: number): Promise> => { - const revenues: Array = [] - - const start = startTimestamp * 1_000 // milliseconds - const end = endTimestamp * 1_000 // milliseconds - - const { data } = await axios.get('https://api.mayachain.shapeshift.com/api/v1/affiliate/fees', { - params: { start, end }, - }) - - const cacaoPriceUsd = await getCacaoPriceUsd() - - const chainId = MAYACHAIN_CHAIN_ID - const assetId = `${chainId}/slip44:${SLIP44.MAYACHAIN}` - - for (const fee of data.fees) { - revenues.push({ - chainId, - assetId, - service: 'mayachain', - txHash: fee.txId, - timestamp: Math.round(fee.timestamp / 1000), - amount: fee.amount, - amountUsd: ((Number(fee.amount) / 1e8) * cacaoPriceUsd).toString(), - }) - } - - return revenues -} diff --git a/node/proxy/api/src/affiliateRevenue/mayachain/constants.ts b/node/proxy/api/src/affiliateRevenue/mayachain/constants.ts new file mode 100644 index 000000000..0aedd7b6d --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/mayachain/constants.ts @@ -0,0 +1,4 @@ +export const MAYACHAIN_API_URL = 'https://api.mayachain.shapeshift.com/api/v1/affiliate/fees' +export const PRICE_API_URL = 'https://api.proxy.shapeshift.com/api/v1/markets/simple/price' +export const MILLISECONDS_PER_SECOND = 1_000 +export const CACAO_DECIMALS = 8 diff --git a/node/proxy/api/src/affiliateRevenue/mayachain/index.ts b/node/proxy/api/src/affiliateRevenue/mayachain/index.ts new file mode 100644 index 000000000..d71e6b8cb --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/mayachain/index.ts @@ -0,0 +1 @@ +export { getFees } from './mayachain' diff --git a/node/proxy/api/src/affiliateRevenue/mayachain/mayachain.ts b/node/proxy/api/src/affiliateRevenue/mayachain/mayachain.ts new file mode 100644 index 000000000..ef6c76994 --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/mayachain/mayachain.ts @@ -0,0 +1,46 @@ +import axios from 'axios' +import { Fees } from '..' +import { MAYACHAIN_CHAIN_ID, SLIP44 } from '../constants' +import { CACAO_DECIMALS, MAYACHAIN_API_URL, MILLISECONDS_PER_SECOND, PRICE_API_URL } from './constants' +import type { FeesResponse } from './types' + +const getCacaoPriceUsd = async (): Promise => { + const { data } = await axios.get<{ cacao: { usd: string } }>(PRICE_API_URL, { + params: { + vs_currencies: 'usd', + ids: 'cacao', + }, + }) + + return Number(data.cacao.usd) +} + +export const getFees = async (startTimestamp: number, endTimestamp: number): Promise> => { + const revenues: Array = [] + + const start = startTimestamp * MILLISECONDS_PER_SECOND + const end = endTimestamp * MILLISECONDS_PER_SECOND + + const { data } = await axios.get(MAYACHAIN_API_URL, { + params: { start, end }, + }) + + const cacaoPriceUsd = await getCacaoPriceUsd() + + const chainId = MAYACHAIN_CHAIN_ID + const assetId = `${chainId}/slip44:${SLIP44.MAYACHAIN}` + + for (const fee of data.fees) { + revenues.push({ + chainId, + assetId, + service: 'mayachain', + txHash: fee.txId, + timestamp: Math.round(fee.timestamp / 1000), + amount: fee.amount, + amountUsd: ((Number(fee.amount) / 10 ** CACAO_DECIMALS) * cacaoPriceUsd).toString(), + }) + } + + return revenues +} diff --git a/node/proxy/api/src/affiliateRevenue/mayachain/types.ts b/node/proxy/api/src/affiliateRevenue/mayachain/types.ts new file mode 100644 index 000000000..9a745eabe --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/mayachain/types.ts @@ -0,0 +1,11 @@ +export type FeesResponse = { + fees: Array<{ + address: string + amount: string + asset: string + blockHash: string + blockHeight: number + timestamp: number + txId: string + }> +} diff --git a/node/proxy/api/src/affiliateRevenue/nearIntents.ts b/node/proxy/api/src/affiliateRevenue/nearIntents.ts deleted file mode 100644 index dc78a7feb..000000000 --- a/node/proxy/api/src/affiliateRevenue/nearIntents.ts +++ /dev/null @@ -1,157 +0,0 @@ -import axios from 'axios' -import { Fees } from '.' -import { - BITCOIN_CHAIN_ID, - DOGECOIN_CHAIN_ID, - SLIP44, - SOLANA_CHAIN_ID, - SUI_CHAIN_ID, - TRON_CHAIN_ID, - ZCASH_CHAIN_ID, -} from './constants' - -const NEAR_INTENTS_API_KEY = process.env.NEAR_INTENTS_API_KEY - -if (!NEAR_INTENTS_API_KEY) throw new Error('NEAR_INTENTS_API_KEY env var not set') - -const NEAR_INTENTS_TO_CHAIN_ID: Record = { - eth: 'eip155:1', - arb: 'eip155:42161', - base: 'eip155:8453', - gnosis: 'eip155:100', - bsc: 'eip155:56', - pol: 'eip155:137', - avax: 'eip155:43114', - op: 'eip155:10', - btc: BITCOIN_CHAIN_ID, - doge: DOGECOIN_CHAIN_ID, - zec: ZCASH_CHAIN_ID, - sol: SOLANA_CHAIN_ID, - tron: TRON_CHAIN_ID, - sui: SUI_CHAIN_ID, - monad: 'eip155:143', -} - -const SLIP44_BY_NETWORK: Record = { - btc: SLIP44.BITCOIN, - doge: SLIP44.DOGECOIN, - zec: SLIP44.ZCASH, - sol: SLIP44.SOLANA, - tron: SLIP44.TRON, - sui: SLIP44.SUI, -} - -const parseNearIntentsAsset = (asset: string): { chainId: string; assetId: string } | null => { - const match = asset.match(/^nep141:(.+)\.omft\.near$/) - if (!match) return null - - const assetPart = match[1] - const tokenMatch = assetPart.match(/^([a-z]+)-0x([a-f0-9]+)$/i) - - if (tokenMatch) { - const network = tokenMatch[1] - const tokenAddress = `0x${tokenMatch[2]}` - const chainId = NEAR_INTENTS_TO_CHAIN_ID[network] - if (!chainId) return null - - if (chainId.startsWith('eip155:')) { - return { chainId, assetId: `${chainId}/erc20:${tokenAddress}` } - } - return { chainId, assetId: `${chainId}/slip44:${SLIP44_BY_NETWORK[network] ?? 0}` } - } - - const network = assetPart - const chainId = NEAR_INTENTS_TO_CHAIN_ID[network] - if (!chainId) return null - - if (chainId.startsWith('eip155:')) { - return { chainId, assetId: `${chainId}/slip44:${SLIP44.ETHEREUM}` } - } - - const slip44 = SLIP44_BY_NETWORK[network] ?? 0 - return { chainId, assetId: `${chainId}/slip44:${slip44}` } -} - -type TransactionsResponse = { - data: Array<{ - originAsset: string - destinationAsset: string - depositAddress: string - recipient: string - status: string - createdAt: string - createdAtTimestamp: number - intentHashes: string - referral: string - amountInFormatted: string - amountOutFormatted: string - appFees: Array<{ - fee: number - recipient: string - }> - nearTxHashes: string[] - originChainTxHashes: string[] - destinationChainTxHashes: string[] - amountIn: string - amountInUsd: string - amountOut: string - amountOutUsd: string - refundTo: string - }> - totalPages: number - page: number - perPage: number - total: number - nextPage?: number - prevPage?: number -} - -// https://docs.near-intents.org/near-intents/integration/distribution-channels/intents-explorer-api -export const getFees = async (startTimestamp: number, endTimestamp: number): Promise> => { - const fees: Array = [] - - let page: number | undefined = 1 - while (page) { - const { data } = (await axios.get( - 'https://explorer.near-intents.org/api/v0/transactions-pages', - { - params: { - referral: 'shapeshift', - page, - perPage: 100, - statuses: 'SUCCESS', - startTimestampUnix: startTimestamp, - endTimestampUnix: endTimestamp, - }, - headers: { Authorization: `Bearer ${NEAR_INTENTS_API_KEY}` }, - } - )) as { data: TransactionsResponse } - - for (const transaction of data.data) { - const parsed = parseNearIntentsAsset(transaction.originAsset) - if (!parsed) { - console.warn(`[nearIntents] Could not parse asset: ${transaction.originAsset}`) - continue - } - - const { chainId, assetId } = parsed - const txHash = transaction.originChainTxHashes[0] ?? '' - - for (const appFee of transaction.appFees) { - fees.push({ - chainId, - assetId, - service: 'nearintents', - txHash, - timestamp: transaction.createdAtTimestamp, - amount: String((parseFloat(transaction.amountIn) * appFee.fee) / 10000), - amountUsd: String((parseFloat(transaction.amountInUsd) * appFee.fee) / 10000), - }) - } - } - - page = data.nextPage - } - - return fees -} diff --git a/node/proxy/api/src/affiliateRevenue/nearIntents/constants.ts b/node/proxy/api/src/affiliateRevenue/nearIntents/constants.ts new file mode 100644 index 000000000..423743b08 --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/nearIntents/constants.ts @@ -0,0 +1,41 @@ +import { + BITCOIN_CHAIN_ID, + DOGECOIN_CHAIN_ID, + SLIP44, + SOLANA_CHAIN_ID, + SUI_CHAIN_ID, + TRON_CHAIN_ID, + ZCASH_CHAIN_ID, +} from '../constants' + +export const NEAR_INTENTS_API_KEY = process.env.NEAR_INTENTS_API_KEY +export const FEE_BPS_DENOMINATOR = 10000 + +if (!NEAR_INTENTS_API_KEY) throw new Error('NEAR_INTENTS_API_KEY env var not set') + +export const NEAR_INTENTS_TO_CHAIN_ID: Record = { + eth: 'eip155:1', + arb: 'eip155:42161', + base: 'eip155:8453', + gnosis: 'eip155:100', + bsc: 'eip155:56', + pol: 'eip155:137', + avax: 'eip155:43114', + op: 'eip155:10', + btc: BITCOIN_CHAIN_ID, + doge: DOGECOIN_CHAIN_ID, + zec: ZCASH_CHAIN_ID, + sol: SOLANA_CHAIN_ID, + tron: TRON_CHAIN_ID, + sui: SUI_CHAIN_ID, + monad: 'eip155:143', +} + +export const SLIP44_BY_NETWORK: Record = { + btc: SLIP44.BITCOIN, + doge: SLIP44.DOGECOIN, + zec: SLIP44.ZCASH, + sol: SLIP44.SOLANA, + tron: SLIP44.TRON, + sui: SLIP44.SUI, +} diff --git a/node/proxy/api/src/affiliateRevenue/nearIntents/index.ts b/node/proxy/api/src/affiliateRevenue/nearIntents/index.ts new file mode 100644 index 000000000..cbe841959 --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/nearIntents/index.ts @@ -0,0 +1 @@ +export { getFees } from './nearIntents' diff --git a/node/proxy/api/src/affiliateRevenue/nearIntents/nearIntents.ts b/node/proxy/api/src/affiliateRevenue/nearIntents/nearIntents.ts new file mode 100644 index 000000000..6b4b7fef0 --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/nearIntents/nearIntents.ts @@ -0,0 +1,79 @@ +import axios from 'axios' +import { Fees } from '..' +import { FEE_BPS_DENOMINATOR, NEAR_INTENTS_API_KEY } from './constants' +import type { TransactionsResponse } from './types' +import { parseNearIntentsAsset, sleep } from './utils' + +const fetchPage = async ( + page: number, + startTimestamp: number, + endTimestamp: number, + retries = 3 +): Promise => { + try { + const { data } = await axios.get( + 'https://explorer.near-intents.org/api/v0/transactions-pages', + { + params: { + referral: 'shapeshift', + page, + perPage: 100, + statuses: 'SUCCESS', + startTimestampUnix: startTimestamp, + endTimestampUnix: endTimestamp, + }, + headers: { Authorization: `Bearer ${NEAR_INTENTS_API_KEY}` }, + } + ) + return data + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 429 && retries > 0) { + console.warn(`[nearIntents] Rate limited, waiting 5s before retry (${retries} retries left)`) + await sleep(5000) + return fetchPage(page, startTimestamp, endTimestamp, retries - 1) + } + throw error + } +} + +export const getFees = async (startTimestamp: number, endTimestamp: number): Promise> => { + const fees: Array = [] + + let page: number | undefined = 1 + + while (page) { + const data = await fetchPage(page, startTimestamp, endTimestamp) + + for (const transaction of data.data) { + const { chainId, assetId } = parseNearIntentsAsset(transaction.originAsset) + const txHash = + transaction.originChainTxHashes[0] || + transaction.nearTxHashes[0] || + transaction.intentHashes || + '' + + for (const appFee of transaction.appFees) { + const feeAmount = (parseFloat(transaction.amountIn) * appFee.fee) / FEE_BPS_DENOMINATOR + const feeUsd = (parseFloat(transaction.amountInUsd) * appFee.fee) / FEE_BPS_DENOMINATOR + + fees.push({ + chainId, + assetId, + service: 'nearintents', + txHash, + timestamp: transaction.createdAtTimestamp, + amount: String(feeAmount), + amountUsd: String(feeUsd), + }) + } + } + + page = data.nextPage + + if (page) { + await sleep(1000) + } + } + + return fees +} diff --git a/node/proxy/api/src/affiliateRevenue/nearIntents/types.ts b/node/proxy/api/src/affiliateRevenue/nearIntents/types.ts new file mode 100644 index 000000000..eb532de21 --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/nearIntents/types.ts @@ -0,0 +1,35 @@ +export type ParseResult = { chainId: string; assetId: string } + +export type TransactionsResponse = { + data: Array<{ + originAsset: string + destinationAsset: string + depositAddress: string + recipient: string + status: string + createdAt: string + createdAtTimestamp: number + intentHashes: string + referral: string + amountInFormatted: string + amountOutFormatted: string + appFees: Array<{ + fee: number + recipient: string + }> + nearTxHashes: string[] + originChainTxHashes: string[] + destinationChainTxHashes: string[] + amountIn: string + amountInUsd: string + amountOut: string + amountOutUsd: string + refundTo: string + }> + totalPages: number + page: number + perPage: number + total: number + nextPage?: number + prevPage?: number +} diff --git a/node/proxy/api/src/affiliateRevenue/nearIntents/utils.ts b/node/proxy/api/src/affiliateRevenue/nearIntents/utils.ts new file mode 100644 index 000000000..1c3afb0c3 --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/nearIntents/utils.ts @@ -0,0 +1,55 @@ +import { SLIP44 } from '../constants' +import { NEAR_INTENTS_TO_CHAIN_ID, SLIP44_BY_NETWORK } from './constants' +import type { ParseResult } from './types' + +export const resolveChainId = (network: string): string | undefined => { + const chainId = NEAR_INTENTS_TO_CHAIN_ID[network] + if (!chainId) { + console.warn(`[nearIntents] Unknown network '${network}' - add to NEAR_INTENTS_TO_CHAIN_ID`) + } + return chainId +} + +export const buildAssetId = (chainId: string, network: string, tokenAddress?: string): string => { + if (chainId.startsWith('unknown:')) { + return tokenAddress ? `${chainId}/unknown:${tokenAddress}` : `${chainId}/native` + } + + if (chainId.startsWith('eip155:')) { + return tokenAddress ? `${chainId}/erc20:${tokenAddress}` : `${chainId}/slip44:${SLIP44.ETHEREUM}` + } + + const slip44 = SLIP44_BY_NETWORK[network] ?? 0 + return `${chainId}/slip44:${slip44}` +} + +export const parseNearIntentsAsset = (asset: string): ParseResult => { + const nep141Match = asset.match(/^nep141:(.+)\.omft\.near$/) + if (nep141Match) { + const assetPart = nep141Match[1] + + const tokenMatch = assetPart.match(/^([a-z]+)-(0x)?([a-f0-9]+)$/i) + if (tokenMatch) { + const network = tokenMatch[1] + const tokenAddress = `0x${tokenMatch[3]}` + const chainId = resolveChainId(network) ?? `unknown:${network}` + return { chainId, assetId: buildAssetId(chainId, network, tokenAddress) } + } + + const network = assetPart + const chainId = resolveChainId(network) ?? `unknown:${network}` + return { chainId, assetId: buildAssetId(chainId, network) } + } + + const nep245Match = asset.match(/^nep245:v2_1\.omni\.hot\.tg:(\d+)_.+$/) + if (nep245Match) { + const chainId = `eip155:${nep245Match[1]}` + return { chainId, assetId: `${chainId}/slip44:${SLIP44.ETHEREUM}` } + } + + const prefix = asset.split(':')[0] ?? 'unknown' + console.warn(`[nearIntents] Unrecognized asset format: ${asset} - update parser`) + return { chainId: `unknown:${prefix}`, assetId: `unknown:${prefix}/unknown` } +} + +export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) diff --git a/node/proxy/api/src/affiliateRevenue/portals.ts b/node/proxy/api/src/affiliateRevenue/portals.ts deleted file mode 100644 index 996d7f548..000000000 --- a/node/proxy/api/src/affiliateRevenue/portals.ts +++ /dev/null @@ -1,118 +0,0 @@ -import axios from 'axios' -import { Fees } from '.' -import { - DAO_TREASURY_ARBITRUM, - DAO_TREASURY_AVALANCHE, - DAO_TREASURY_BASE, - DAO_TREASURY_BSC, - DAO_TREASURY_ETHEREUM, - DAO_TREASURY_GNOSIS, - DAO_TREASURY_OPTIMISM, - DAO_TREASURY_POLYGON, - SLIP44, -} from './constants' - -const NETWORK_TO_CHAIN_ID: Record = { - arbitrum: 'eip155:42161', - avalanche: 'eip155:43114', - base: 'eip155:8453', - bsc: 'eip155:56', - ethereum: 'eip155:1', - gnosis: 'eip155:100', - optimism: 'eip155:10', - polygon: 'eip155:137', -} - -const TREASURY_ADDRESSES = [ - DAO_TREASURY_ARBITRUM, - DAO_TREASURY_AVALANCHE, - DAO_TREASURY_BASE, - DAO_TREASURY_BSC, - DAO_TREASURY_ETHEREUM, - DAO_TREASURY_GNOSIS, - DAO_TREASURY_OPTIMISM, - DAO_TREASURY_POLYGON, -] - -type TokenDetails = { - key: string - name: string - decimals: number - symbol: string - price: number - address: string - platform: string - network: string - image: string - tokenId: string -} - -type TradesResponse = { - trades: Array<{ - txHash: string - timestamp: number - relativeTime: string - inputToken: string - inputTokenDetails: TokenDetails - inputAmount: string - inputValueUsd: number - outputToken: string - outputTokenDetails: TokenDetails - outputAmount: string - outputValueUsd: number - broadcaster: string - partner: string - sender: string - partnerFeeUsd: number - partnerFeeAmount: string - partnerFeeToken: TokenDetails - }> - totalCount: number - page: number - limit: number - latestTimestamp: number -} - -export const getFees = async (startTimestamp: number, endTimestamp: number): Promise> => { - const fees: Array = [] - - let page = 0 - let more = true - while (more) { - const { data } = await axios.get('https://build.portals.fi/api/dashboard/trades', { - params: { - from: startTimestamp, - to: endTimestamp, - page, - partners: TREASURY_ADDRESSES.join(','), - }, - }) - - for (const trade of data.trades) { - const token = trade.partnerFeeToken - - if (!token) continue - - const chainId = NETWORK_TO_CHAIN_ID[token.network] - if (!chainId) throw new Error(`unsupported network: ${token.network}`) - - const assetId = token.platform === 'native' ? `${chainId}/slip44:${SLIP44.ETHEREUM}` : `${chainId}/erc20:${token.address}` - - fees.push({ - chainId, - assetId, - service: 'portals', - txHash: trade.txHash, - timestamp: trade.timestamp, - amount: trade.partnerFeeAmount, - amountUsd: String(trade.partnerFeeUsd), - }) - } - - if (data.trades.length < data.limit) more = false - - page++ - } - - return fees -} diff --git a/node/proxy/api/src/affiliateRevenue/portals/constants.ts b/node/proxy/api/src/affiliateRevenue/portals/constants.ts new file mode 100644 index 000000000..03052a118 --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/portals/constants.ts @@ -0,0 +1,103 @@ +import { + DAO_TREASURY_ARBITRUM, + DAO_TREASURY_AVALANCHE, + DAO_TREASURY_BASE, + DAO_TREASURY_BSC, + DAO_TREASURY_ETHEREUM, + DAO_TREASURY_GNOSIS, + DAO_TREASURY_OPTIMISM, + DAO_TREASURY_POLYGON, +} from '../constants' +import type { ChainConfig } from './types' + +export const AFFILIATE_FEE_BPS = 55 +export const FEE_BPS_DENOMINATOR = 10000 + +export const PORTAL_EVENT_SIGNATURE = '0x5915121a82fd755e41c1e456ef88c033de2bce9c1293024de10dddb0e2f4a101' +export const PORTAL_EVENT_ABI = [ + { type: 'address', name: 'inputToken' }, + { type: 'uint256', name: 'inputAmount' }, + { type: 'address', name: 'outputToken' }, + { type: 'uint256', name: 'outputAmount' }, + { type: 'address', name: 'recipient' }, +] as const + +export const CHAIN_CONFIGS: ChainConfig[] = [ + { + chainId: 'eip155:1', + network: 'ethereum', + router: '0xbf5a7f3629fb325e2a8453d595ab103465f75e62', + treasury: DAO_TREASURY_ETHEREUM, + explorerType: 'blockscout', + explorerUrl: 'https://eth.blockscout.com', + }, + { + chainId: 'eip155:42161', + network: 'arbitrum', + router: '0x34b6a821d2f26c6b7cdb01cd91895170c6574a0d', + treasury: DAO_TREASURY_ARBITRUM, + explorerType: 'blockscout', + explorerUrl: 'https://arbitrum.blockscout.com', + }, + { + chainId: 'eip155:10', + network: 'optimism', + router: '0x43838f0c0d499f5c3101589f0f452b1fc7515178', + treasury: DAO_TREASURY_OPTIMISM, + explorerType: 'blockscout', + explorerUrl: 'https://optimism.blockscout.com', + }, + { + chainId: 'eip155:8453', + network: 'base', + router: '0xb0324286b3ef7dddc93fb2ff7c8b7b8a3524803c', + treasury: DAO_TREASURY_BASE, + explorerType: 'blockscout', + explorerUrl: 'https://base.blockscout.com', + }, + { + chainId: 'eip155:137', + network: 'polygon', + router: '0xC74063fdb47fe6dCE6d029A489BAb37b167Da57f', + treasury: DAO_TREASURY_POLYGON, + explorerType: 'blockscout', + explorerUrl: 'https://polygon.blockscout.com', + }, + { + chainId: 'eip155:100', + network: 'gnosis', + router: '0x8e74454b2cf2f6cc2a06083ef122187551cf391c', + treasury: DAO_TREASURY_GNOSIS, + explorerType: 'blockscout', + explorerUrl: 'https://gnosis.blockscout.com', + }, + { + chainId: 'eip155:56', + network: 'bsc', + router: '0x34b6a821d2f26c6b7cdb01cd91895170c6574a0d', + treasury: DAO_TREASURY_BSC, + explorerType: 'etherscan', + explorerUrl: 'https://api.bscscan.com', + }, + { + chainId: 'eip155:43114', + network: 'avalanche', + router: '0xbf5A7F3629fB325E2a8453D595AB103465F75E62', + treasury: DAO_TREASURY_AVALANCHE, + explorerType: 'etherscan', + explorerUrl: 'https://api.snowtrace.io', + }, +] + +export const COINGECKO_CHAINS: Record = { + '1': { platform: 'ethereum', nativeCoinId: 'ethereum' }, + '42161': { platform: 'arbitrum-one', nativeCoinId: 'ethereum' }, + '10': { platform: 'optimistic-ethereum', nativeCoinId: 'ethereum' }, + '8453': { platform: 'base', nativeCoinId: 'ethereum' }, + '137': { platform: 'polygon-pos', nativeCoinId: 'matic-network' }, + '100': { platform: 'xdai', nativeCoinId: 'xdai' }, + '56': { platform: 'binance-smart-chain', nativeCoinId: 'binancecoin' }, + '43114': { platform: 'avalanche', nativeCoinId: 'avalanche-2' }, +} + +export const COINGECKO_API_BASE = 'https://api.proxy.shapeshift.com/api/v1/markets' diff --git a/node/proxy/api/src/affiliateRevenue/portals/index.ts b/node/proxy/api/src/affiliateRevenue/portals/index.ts new file mode 100644 index 000000000..3d38d567b --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/portals/index.ts @@ -0,0 +1 @@ +export { getFees } from './portals' diff --git a/node/proxy/api/src/affiliateRevenue/portals/portals.ts b/node/proxy/api/src/affiliateRevenue/portals/portals.ts new file mode 100644 index 000000000..c4aa4bc72 --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/portals/portals.ts @@ -0,0 +1,269 @@ +import axios from 'axios' +import { padHex, zeroAddress } from 'viem' +import { Fees } from '..' +import { CHAIN_CONFIGS, PORTAL_EVENT_SIGNATURE } from './constants' +import type { + BlockscoutLogsResponse, + BlockscoutTokenTransfersResponse, + ChainConfig, + EtherscanLogsResponse, + EtherscanTokenTxResponse, + PortalEventData, + TokenTransfer, +} from './types' +import { + buildAssetId, + calculateFallbackFee, + decodePortalEventData, + getTokenDecimals, + getTokenPrice, + getTransactionTimestamp, +} from './utils' + +const getPortalEventsBlockscout = async ( + config: ChainConfig, + startTimestamp: number, + endTimestamp: number +): Promise => { + const events: PortalEventData[] = [] + const treasuryLower = config.treasury.toLowerCase() + const txTimestampCache: Record = {} + + let nextPageParams: BlockscoutLogsResponse['next_page_params'] = undefined + let reachedStartTimestamp = false + + do { + const params = new URLSearchParams() + if (nextPageParams) { + params.set('block_number', nextPageParams.block_number.toString()) + params.set('index', nextPageParams.index.toString()) + } + + const url = `${config.explorerUrl}/api/v2/addresses/${config.router}/logs?${params.toString()}` + const { data } = await axios.get(url) + + for (const log of data.items) { + if (!log.decoded?.parameters) continue + + const partnerParam = log.decoded.parameters.find((p) => p.name === 'partner') + if (!partnerParam || partnerParam.value.toLowerCase() !== treasuryLower) continue + + let logTimestamp = txTimestampCache[log.transaction_hash] + if (!logTimestamp) { + logTimestamp = await getTransactionTimestamp(config.explorerUrl, log.transaction_hash) + txTimestampCache[log.transaction_hash] = logTimestamp + } + + if (logTimestamp < startTimestamp) { + reachedStartTimestamp = true + continue + } + if (logTimestamp > endTimestamp) continue + + const inputToken = log.decoded.parameters.find((p) => p.name === 'inputToken')?.value ?? '' + const inputAmount = log.decoded.parameters.find((p) => p.name === 'inputAmount')?.value ?? '0' + const outputToken = log.decoded.parameters.find((p) => p.name === 'outputToken')?.value ?? '' + const outputAmount = log.decoded.parameters.find((p) => p.name === 'outputAmount')?.value ?? '0' + + events.push({ + txHash: log.transaction_hash, + timestamp: logTimestamp, + inputToken, + inputAmount, + outputToken, + outputAmount, + }) + } + + if (reachedStartTimestamp && data.items.length > 0) { + nextPageParams = undefined + } else { + nextPageParams = data.next_page_params + } + } while (nextPageParams) + + return events +} + +const getFeeTransferBlockscout = async (config: ChainConfig, txHash: string): Promise => { + const treasuryLower = config.treasury.toLowerCase() + + const url = `${config.explorerUrl}/api/v2/transactions/${txHash}/token-transfers` + const { data } = await axios.get(url) + + for (const transfer of data.items) { + const toAddress = transfer.to?.hash + if (!toAddress) continue + + if (toAddress.toLowerCase() === treasuryLower) { + const tokenAddress = transfer.token?.address_hash + if (!tokenAddress) continue + + return { + token: tokenAddress, + amount: transfer.total?.value ?? '0', + decimals: parseInt(transfer.total?.decimals ?? '18'), + symbol: transfer.token?.symbol ?? 'UNKNOWN', + } + } + } + + return null +} + +const getPortalEventsEtherscan = async ( + config: ChainConfig, + startTimestamp: number, + endTimestamp: number +): Promise => { + const events: PortalEventData[] = [] + const treasuryTopic = padHex(config.treasury.toLowerCase() as `0x${string}`, { size: 32 }) + + const url = `${config.explorerUrl}/api` + const { data } = await axios.get(url, { + params: { + module: 'logs', + action: 'getLogs', + address: config.router, + topic0: PORTAL_EVENT_SIGNATURE, + topic0_3_opr: 'and', + topic3: treasuryTopic, + fromBlock: 0, + toBlock: 'latest', + sort: 'desc', + }, + }) + + if (data.status !== '1' || !Array.isArray(data.result)) { + return events + } + + for (const log of data.result) { + const logTimestamp = parseInt(log.timeStamp, 16) + if (logTimestamp < startTimestamp) break + if (logTimestamp > endTimestamp) continue + + const decoded = decodePortalEventData(log.data) + if (!decoded) continue + + events.push({ + txHash: log.transactionHash, + timestamp: logTimestamp, + inputToken: decoded.inputToken, + inputAmount: decoded.inputAmount, + outputToken: decoded.outputToken, + outputAmount: decoded.outputAmount, + }) + } + + return events +} + +const getFeeTransferEtherscan = async (config: ChainConfig, txHash: string): Promise => { + const treasuryLower = config.treasury.toLowerCase() + + const url = `${config.explorerUrl}/api` + const { data } = await axios.get(url, { + params: { + module: 'account', + action: 'tokentx', + txhash: txHash, + }, + }) + + if (data.status !== '1' || !Array.isArray(data.result)) { + return null + } + + for (const transfer of data.result) { + if (transfer.to.toLowerCase() === treasuryLower) { + return { + token: transfer.contractAddress, + amount: transfer.value, + decimals: parseInt(transfer.tokenDecimal), + symbol: transfer.tokenSymbol, + } + } + } + + return null +} + +const getFeesForChain = async (config: ChainConfig, startTimestamp: number, endTimestamp: number): Promise => { + const fees: Fees[] = [] + + const events = + config.explorerType === 'blockscout' + ? await getPortalEventsBlockscout(config, startTimestamp, endTimestamp) + : await getPortalEventsEtherscan(config, startTimestamp, endTimestamp) + + for (const event of events) { + try { + let feeTransfer: TokenTransfer | null = null + + try { + feeTransfer = + config.explorerType === 'blockscout' + ? await getFeeTransferBlockscout(config, event.txHash) + : await getFeeTransferEtherscan(config, event.txHash) + } catch { + // Fall through to fallback calculation + } + + if (feeTransfer) { + const assetId = buildAssetId(config.chainId, feeTransfer.token ?? zeroAddress) + const amountDecimal = Number(feeTransfer.amount) / 10 ** feeTransfer.decimals + const price = await getTokenPrice(config.chainId, feeTransfer.token ?? '') + const amountUsd = price ? (amountDecimal * price).toString() : undefined + + fees.push({ + chainId: config.chainId, + assetId, + service: 'portals', + txHash: event.txHash, + timestamp: event.timestamp, + amount: feeTransfer.amount, + amountUsd, + }) + } else { + const inputToken = event.inputToken ?? zeroAddress + const assetId = buildAssetId(config.chainId, inputToken) + const decimals = await getTokenDecimals(config.explorerUrl, config.explorerType, inputToken) + const feeWei = calculateFallbackFee(event.inputAmount) + const feeDecimal = Number(feeWei) / 10 ** decimals + const price = await getTokenPrice(config.chainId, inputToken) + const amountUsd = price ? (feeDecimal * price).toString() : undefined + + fees.push({ + chainId: config.chainId, + assetId, + service: 'portals', + txHash: event.txHash, + timestamp: event.timestamp, + amount: feeWei, + amountUsd, + }) + } + } catch { + // Skip failed transactions + } + } + + return fees +} + +export const getFees = async (startTimestamp: number, endTimestamp: number): Promise> => { + const allFees: Fees[] = [] + + const results = await Promise.allSettled( + CHAIN_CONFIGS.map((config) => getFeesForChain(config, startTimestamp, endTimestamp)) + ) + + for (const result of results) { + if (result.status === 'fulfilled') { + allFees.push(...result.value) + } + } + + return allFees +} diff --git a/node/proxy/api/src/affiliateRevenue/portals/types.ts b/node/proxy/api/src/affiliateRevenue/portals/types.ts new file mode 100644 index 000000000..be89e44bb --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/portals/types.ts @@ -0,0 +1,95 @@ +export type ExplorerType = 'blockscout' | 'etherscan' + +export type ChainConfig = { + chainId: string + network: string + router: string + treasury: string + explorerType: ExplorerType + explorerUrl: string +} + +export type PortalEventData = { + txHash: string + timestamp: number + inputToken: string + inputAmount: string + outputToken: string + outputAmount: string +} + +export type TokenTransfer = { + token: string + amount: string + decimals: number + symbol: string +} + +export type BlockscoutLogItem = { + transaction_hash: string + block_number: number + decoded?: { + parameters: Array<{ + name: string + value: string + indexed: boolean + }> + } +} + +export type BlockscoutTransaction = { + timestamp: string + hash: string +} + +export type BlockscoutLogsResponse = { + items: BlockscoutLogItem[] + next_page_params?: { block_number: number; index: number } +} + +export type BlockscoutTokenTransfer = { + from: { hash: string } + to: { hash: string } + token: { address_hash: string; symbol: string; decimals: string } + total: { value: string; decimals: string } +} + +export type BlockscoutTokenTransfersResponse = { + items: BlockscoutTokenTransfer[] +} + +export type EtherscanLogResult = { + transactionHash: string + blockNumber: string + timeStamp: string + topics: string[] + data: string +} + +export type EtherscanLogsResponse = { + status: string + message: string + result: EtherscanLogResult[] +} + +export type EtherscanTokenTxResult = { + from: string + to: string + contractAddress: string + tokenSymbol: string + tokenDecimal: string + value: string +} + +export type EtherscanTokenTxResponse = { + status: string + message: string + result: EtherscanTokenTxResult[] +} + +export type DecodedPortalEvent = { + inputToken: string + inputAmount: string + outputToken: string + outputAmount: string +} diff --git a/node/proxy/api/src/affiliateRevenue/portals/utils.ts b/node/proxy/api/src/affiliateRevenue/portals/utils.ts new file mode 100644 index 000000000..079d280ce --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/portals/utils.ts @@ -0,0 +1,87 @@ +import axios from 'axios' +import { decodeAbiParameters, zeroAddress } from 'viem' +import { SLIP44 } from '../constants' +import { AFFILIATE_FEE_BPS, COINGECKO_API_BASE, COINGECKO_CHAINS, FEE_BPS_DENOMINATOR, PORTAL_EVENT_ABI } from './constants' +import type { BlockscoutTransaction, DecodedPortalEvent, ExplorerType } from './types' + +export const getTransactionTimestamp = async (explorerUrl: string, txHash: string): Promise => { + const url = `${explorerUrl}/api/v2/transactions/${txHash}` + const { data } = await axios.get(url) + return Math.floor(new Date(data.timestamp).getTime() / 1000) +} + +export const decodePortalEventData = (data: string): DecodedPortalEvent | null => { + if (!data || data.length < 258) return null + + try { + const decoded = decodeAbiParameters(PORTAL_EVENT_ABI, data as `0x${string}`) + return { + inputToken: decoded[0], + inputAmount: decoded[1].toString(), + outputToken: decoded[2], + outputAmount: decoded[3].toString(), + } + } catch { + return null + } +} + +export const calculateFallbackFee = (inputAmount: string): string => { + const amount = BigInt(inputAmount) + const fee = (amount * BigInt(AFFILIATE_FEE_BPS)) / BigInt(FEE_BPS_DENOMINATOR) + return fee.toString() +} + +export const getTokenDecimals = async ( + explorerUrl: string, + explorerType: ExplorerType, + tokenAddress: string +): Promise => { + if (tokenAddress.toLowerCase() === zeroAddress) return 18 + + try { + if (explorerType === 'blockscout') { + const { data } = await axios.get<{ decimals?: string }>(`${explorerUrl}/api/v2/tokens/${tokenAddress}`) + return parseInt(data.decimals ?? '18') + } + + const { data } = await axios.get<{ result?: Array<{ divisor?: string }> }>(`${explorerUrl}/api`, { + params: { module: 'token', action: 'tokeninfo', contractaddress: tokenAddress }, + }) + return parseInt(data.result?.[0]?.divisor ?? '18') + } catch { + return 18 + } +} + +export const buildAssetId = (chainId: string, tokenAddress: string): string => { + const tokenLower = tokenAddress.toLowerCase() + const isNative = tokenLower === zeroAddress + return isNative ? `${chainId}/slip44:${SLIP44.ETHEREUM}` : `${chainId}/erc20:${tokenLower}` +} + +export const getTokenPrice = async (chainId: string, tokenAddress: string): Promise => { + try { + const networkId = chainId.split(':')[1] + const chainConfig = COINGECKO_CHAINS[networkId] + if (!chainConfig) return null + + const tokenLower = tokenAddress.toLowerCase() + const isNative = tokenLower === zeroAddress + + if (isNative) { + const { data } = await axios.get>( + `${COINGECKO_API_BASE}/simple/price`, + { params: { vs_currencies: 'usd', ids: chainConfig.nativeCoinId } } + ) + return data[chainConfig.nativeCoinId]?.usd ?? null + } + + const { data } = await axios.get<{ market_data?: { current_price?: { usd?: number } } }>( + `${COINGECKO_API_BASE}/coins/${chainConfig.platform}/contract/${tokenLower}` + ) + return data.market_data?.current_price?.usd ?? null + } catch { + return null + } +} diff --git a/node/proxy/api/src/affiliateRevenue/relay.ts b/node/proxy/api/src/affiliateRevenue/relay.ts deleted file mode 100644 index c6aa7b50e..000000000 --- a/node/proxy/api/src/affiliateRevenue/relay.ts +++ /dev/null @@ -1,178 +0,0 @@ -import axios from 'axios' -import { Fees } from '.' -import { - BITCOIN_CHAIN_ID, - DAO_TREASURY_BASE, - SLIP44, - SOLANA_CHAIN_ID, - TRON_CHAIN_ID, -} from './constants' - -const RELAY_API_URL = 'https://api.relay.link' -const SHAPESHIFT_REFERRER = 'shapeshift' - -// Non-EVM chains need explicit CAIP-2 chain ID mapping -// EVM chains automatically use eip155:${chainId} pattern -const NON_EVM_CHAINS: Record = { - 792703809: { chainId: SOLANA_CHAIN_ID, slip44: SLIP44.SOLANA }, // Solana - 8253038: { chainId: BITCOIN_CHAIN_ID, slip44: SLIP44.BITCOIN }, // Bitcoin - 728126428: { chainId: TRON_CHAIN_ID, slip44: SLIP44.TRON }, // Tron - // Eclipse and Soon don't have canonical CAIP IDs yet - keep as placeholders - 9286185: { chainId: 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z', slip44: SLIP44.SOLANA }, // Eclipse (SVM) - 9286186: { chainId: 'solana:soon', slip44: SLIP44.SOLANA }, // Soon (SVM) -} - -type AppFee = { - recipient: string - bps: string - amount: string - amountUsd: string - amountUsdCurrent?: string -} - -type CurrencyObject = { - chainId: number - address: string - symbol: string - name: string - decimals: number -} - -type InTx = { - chainId: number - hash: string - timestamp: number -} - -type RequestData = { - appFees?: AppFee[] - paidAppFees?: AppFee[] - feeCurrencyObject?: CurrencyObject - inTxs?: InTx[] - metadata?: { - currencyIn?: { - currency?: CurrencyObject - } - } -} - -type RelayRequest = { - id: string - status: string - user: string - recipient: string - createdAt: string - updatedAt: string - data: RequestData -} - -type RelayResponse = { - requests: RelayRequest[] - continuation?: string -} - -const isLikelyNonEvm = (chainId: number): boolean => { - // Non-EVM chains in Relay typically have very large chain IDs (>1M) - // EVM chains are typically < 1M - return chainId > 1_000_000 -} - -const getChainConfig = (numericChainId: number): { chainId: string; slip44: number; isEvm: boolean } => { - const nonEvmConfig = NON_EVM_CHAINS[numericChainId] - if (nonEvmConfig) { - return { ...nonEvmConfig, isEvm: false } - } - - // For unknown chains with large IDs (likely non-EVM), use a generic format - // This ensures we still capture fees even for chains we don't explicitly support - if (isLikelyNonEvm(numericChainId)) { - return { - chainId: `unknown:${numericChainId}`, - slip44: 0, - isEvm: false, - } - } - - // Default to EVM chain (small chain IDs are typically EVM) - return { - chainId: `eip155:${numericChainId}`, - slip44: 60, - isEvm: true, - } -} - -const buildAssetId = ( - chainId: string, - slip44: number, - tokenAddress: string, - isEvm: boolean -): string => { - const normalizedAddress = tokenAddress.toLowerCase() - const isNativeToken = - normalizedAddress === '0x0000000000000000000000000000000000000000' || - normalizedAddress === '11111111111111111111111111111111' // Solana native - - if (isNativeToken) { - return `${chainId}/slip44:${slip44}` - } - - if (isEvm) { - return `${chainId}/erc20:${normalizedAddress}` - } - - // For non-EVM tokens, fall back to native slip44 - return `${chainId}/slip44:${slip44}` -} - -export const getFees = async (startTimestamp: number, endTimestamp: number): Promise> => { - const fees: Array = [] - let continuation: string | undefined - - do { - const { data } = await axios.get(`${RELAY_API_URL}/requests/v2`, { - params: { - referrer: SHAPESHIFT_REFERRER, - startTimestamp, - endTimestamp, - status: 'success', - continuation, - }, - }) - - for (const request of data.requests) { - const appFees = request.data?.appFees ?? [] - - const relevantFees = appFees.filter( - (fee) => fee.recipient.toLowerCase() === DAO_TREASURY_BASE.toLowerCase() - ) - - if (relevantFees.length === 0) continue - - const currencyObject = - request.data?.feeCurrencyObject ?? request.data?.metadata?.currencyIn?.currency - if (!currencyObject) continue - - const { chainId, slip44, isEvm } = getChainConfig(currencyObject.chainId) - const assetId = buildAssetId(chainId, slip44, currencyObject.address, isEvm) - - const txHash = request.data?.inTxs?.[0]?.hash ?? '' - const timestamp = Math.floor(new Date(request.createdAt).getTime() / 1000) - - for (const appFee of relevantFees) { - fees.push({ - chainId, - assetId, - service: 'relay', - txHash, - timestamp, - amount: appFee.amount, - amountUsd: appFee.amountUsd, - }) - } - } - - continuation = data.continuation - } while (continuation) - - return fees -} diff --git a/node/proxy/api/src/affiliateRevenue/relay/constants.ts b/node/proxy/api/src/affiliateRevenue/relay/constants.ts new file mode 100644 index 000000000..53f5813dc --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/relay/constants.ts @@ -0,0 +1,12 @@ +import { BITCOIN_CHAIN_ID, SLIP44, SOLANA_CHAIN_ID, TRON_CHAIN_ID } from '../constants' + +export const RELAY_API_URL = 'https://api.relay.link' +export const SHAPESHIFT_REFERRER = 'shapeshift' + +export const NON_EVM_CHAINS: Record = { + 792703809: { chainId: SOLANA_CHAIN_ID, slip44: SLIP44.SOLANA }, + 8253038: { chainId: BITCOIN_CHAIN_ID, slip44: SLIP44.BITCOIN }, + 728126428: { chainId: TRON_CHAIN_ID, slip44: SLIP44.TRON }, + 9286185: { chainId: 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z', slip44: SLIP44.SOLANA }, + 9286186: { chainId: 'solana:soon', slip44: SLIP44.SOLANA }, +} diff --git a/node/proxy/api/src/affiliateRevenue/relay/index.ts b/node/proxy/api/src/affiliateRevenue/relay/index.ts new file mode 100644 index 000000000..f1b5fc2af --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/relay/index.ts @@ -0,0 +1 @@ +export { getFees } from './relay' diff --git a/node/proxy/api/src/affiliateRevenue/relay/relay.ts b/node/proxy/api/src/affiliateRevenue/relay/relay.ts new file mode 100644 index 000000000..7760435a3 --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/relay/relay.ts @@ -0,0 +1,59 @@ +import axios from 'axios' +import { Fees } from '..' +import { DAO_TREASURY_BASE } from '../constants' +import { RELAY_API_URL, SHAPESHIFT_REFERRER } from './constants' +import type { RelayResponse } from './types' +import { buildAssetId, getChainConfig } from './utils' + +export const getFees = async (startTimestamp: number, endTimestamp: number): Promise> => { + const fees: Array = [] + let continuation: string | undefined + + do { + const { data } = await axios.get(`${RELAY_API_URL}/requests/v2`, { + params: { + referrer: SHAPESHIFT_REFERRER, + startTimestamp, + endTimestamp, + status: 'success', + continuation, + }, + }) + + for (const request of data.requests) { + const appFees = request.data?.appFees ?? [] + + const relevantFees = appFees.filter( + (fee) => fee.recipient.toLowerCase() === DAO_TREASURY_BASE.toLowerCase() + ) + + if (relevantFees.length === 0) continue + + const currencyObject = + request.data?.feeCurrencyObject ?? request.data?.metadata?.currencyIn?.currency + if (!currencyObject) continue + + const { chainId, slip44, isEvm } = getChainConfig(currencyObject.chainId) + const assetId = buildAssetId(chainId, slip44, currencyObject.address, isEvm) + + const txHash = request.data?.inTxs?.[0]?.hash ?? '' + const timestamp = Math.floor(new Date(request.createdAt).getTime() / 1000) + + for (const appFee of relevantFees) { + fees.push({ + chainId, + assetId, + service: 'relay', + txHash, + timestamp, + amount: appFee.amount, + amountUsd: appFee.amountUsd, + }) + } + } + + continuation = data.continuation + } while (continuation) + + return fees +} diff --git a/node/proxy/api/src/affiliateRevenue/relay/types.ts b/node/proxy/api/src/affiliateRevenue/relay/types.ts new file mode 100644 index 000000000..ff4713b4d --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/relay/types.ts @@ -0,0 +1,48 @@ +export type AppFee = { + recipient: string + bps: string + amount: string + amountUsd: string + amountUsdCurrent?: string +} + +export type CurrencyObject = { + chainId: number + address: string + symbol: string + name: string + decimals: number +} + +export type InTx = { + chainId: number + hash: string + timestamp: number +} + +export type RequestData = { + appFees?: AppFee[] + paidAppFees?: AppFee[] + feeCurrencyObject?: CurrencyObject + inTxs?: InTx[] + metadata?: { + currencyIn?: { + currency?: CurrencyObject + } + } +} + +export type RelayRequest = { + id: string + status: string + user: string + recipient: string + createdAt: string + updatedAt: string + data: RequestData +} + +export type RelayResponse = { + requests: RelayRequest[] + continuation?: string +} diff --git a/node/proxy/api/src/affiliateRevenue/relay/utils.ts b/node/proxy/api/src/affiliateRevenue/relay/utils.ts new file mode 100644 index 000000000..48695f1c8 --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/relay/utils.ts @@ -0,0 +1,48 @@ +import { NON_EVM_CHAINS } from './constants' + +export const isLikelyNonEvm = (chainId: number): boolean => { + return chainId > 1_000_000 +} + +export const getChainConfig = (numericChainId: number): { chainId: string; slip44: number; isEvm: boolean } => { + const nonEvmConfig = NON_EVM_CHAINS[numericChainId] + if (nonEvmConfig) { + return { ...nonEvmConfig, isEvm: false } + } + + if (isLikelyNonEvm(numericChainId)) { + return { + chainId: `unknown:${numericChainId}`, + slip44: 0, + isEvm: false, + } + } + + return { + chainId: `eip155:${numericChainId}`, + slip44: 60, + isEvm: true, + } +} + +export const buildAssetId = ( + chainId: string, + slip44: number, + tokenAddress: string, + isEvm: boolean +): string => { + const normalizedAddress = tokenAddress.toLowerCase() + const isNativeToken = + normalizedAddress === '0x0000000000000000000000000000000000000000' || + normalizedAddress === '11111111111111111111111111111111' + + if (isNativeToken) { + return `${chainId}/slip44:${slip44}` + } + + if (isEvm) { + return `${chainId}/erc20:${normalizedAddress}` + } + + return `${chainId}/slip44:${slip44}` +} diff --git a/node/proxy/api/src/affiliateRevenue/thorchain/constants.ts b/node/proxy/api/src/affiliateRevenue/thorchain/constants.ts new file mode 100644 index 000000000..83934c199 --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/thorchain/constants.ts @@ -0,0 +1,4 @@ +export const THORCHAIN_API_URL = 'https://api.thorchain.shapeshift.com/api/v1/affiliate/fees' +export const PRICE_API_URL = 'https://api.proxy.shapeshift.com/api/v1/markets/simple/price' +export const MILLISECONDS_PER_SECOND = 1_000 +export const RUNE_DECIMALS = 8 diff --git a/node/proxy/api/src/affiliateRevenue/thorchain/index.ts b/node/proxy/api/src/affiliateRevenue/thorchain/index.ts new file mode 100644 index 000000000..c6fcbeaad --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/thorchain/index.ts @@ -0,0 +1 @@ +export { getFees } from './thorchain' diff --git a/node/proxy/api/src/affiliateRevenue/thorchain.ts b/node/proxy/api/src/affiliateRevenue/thorchain/thorchain.ts similarity index 50% rename from node/proxy/api/src/affiliateRevenue/thorchain.ts rename to node/proxy/api/src/affiliateRevenue/thorchain/thorchain.ts index 3ccb653f7..0ecf0da3f 100644 --- a/node/proxy/api/src/affiliateRevenue/thorchain.ts +++ b/node/proxy/api/src/affiliateRevenue/thorchain/thorchain.ts @@ -1,29 +1,16 @@ import axios from 'axios' -import { Fees } from '.' -import { SLIP44, THORCHAIN_CHAIN_ID } from './constants' - -type FeesResponse = { - fees: Array<{ - address: string - amount: string - asset: string - blockHash: string - blockHeight: number - timestamp: number - txId: string - }> -} +import { Fees } from '..' +import { SLIP44, THORCHAIN_CHAIN_ID } from '../constants' +import { MILLISECONDS_PER_SECOND, PRICE_API_URL, RUNE_DECIMALS, THORCHAIN_API_URL } from './constants' +import type { FeesResponse } from './types' const getRunePriceUsd = async (): Promise => { - const { data } = await axios.get<{ thorchain: { usd: string } }>( - 'https://api.proxy.shapeshift.com/api/v1/markets/simple/price', - { - params: { - vs_currencies: 'usd', - ids: 'thorchain', - }, - } - ) + const { data } = await axios.get<{ thorchain: { usd: string } }>(PRICE_API_URL, { + params: { + vs_currencies: 'usd', + ids: 'thorchain', + }, + }) return Number(data.thorchain.usd) } @@ -31,10 +18,10 @@ const getRunePriceUsd = async (): Promise => { export const getFees = async (startTimestamp: number, endTimestamp: number): Promise> => { const fees: Array = [] - const start = startTimestamp * 1_000 // milliseconds - const end = endTimestamp * 1_000 // milliseconds + const start = startTimestamp * MILLISECONDS_PER_SECOND + const end = endTimestamp * MILLISECONDS_PER_SECOND - const { data } = await axios.get('https://api.thorchain.shapeshift.com/api/v1/affiliate/fees', { + const { data } = await axios.get(THORCHAIN_API_URL, { params: { start, end }, }) @@ -51,7 +38,7 @@ export const getFees = async (startTimestamp: number, endTimestamp: number): Pro txHash: fee.txId, timestamp: Math.round(fee.timestamp / 1000), amount: fee.amount, - amountUsd: ((Number(fee.amount) / 1e8) * runePriceUsd).toString(), + amountUsd: ((Number(fee.amount) / 10 ** RUNE_DECIMALS) * runePriceUsd).toString(), }) } diff --git a/node/proxy/api/src/affiliateRevenue/thorchain/types.ts b/node/proxy/api/src/affiliateRevenue/thorchain/types.ts new file mode 100644 index 000000000..9a745eabe --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/thorchain/types.ts @@ -0,0 +1,11 @@ +export type FeesResponse = { + fees: Array<{ + address: string + amount: string + asset: string + blockHash: string + blockHeight: number + timestamp: number + txId: string + }> +} diff --git a/node/proxy/api/src/affiliateRevenue/zrx/constants.ts b/node/proxy/api/src/affiliateRevenue/zrx/constants.ts new file mode 100644 index 000000000..8c6816714 --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/zrx/constants.ts @@ -0,0 +1,10 @@ +import { NATIVE_TOKEN_ADDRESS } from '../constants' + +export { NATIVE_TOKEN_ADDRESS } + +export const ZRX_API_KEY = process.env.ZRX_API_KEY + +if (!ZRX_API_KEY) throw new Error('ZRX_API_KEY env var not set') + +export const ZRX_API_URL = 'https://api.0x.org/trade-analytics' +export const SERVICES = ['swap', 'gasless'] as const diff --git a/node/proxy/api/src/affiliateRevenue/zrx/index.ts b/node/proxy/api/src/affiliateRevenue/zrx/index.ts new file mode 100644 index 000000000..e00a6aa65 --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/zrx/index.ts @@ -0,0 +1 @@ +export { getFees } from './zrx' diff --git a/node/proxy/api/src/affiliateRevenue/zrx/types.ts b/node/proxy/api/src/affiliateRevenue/zrx/types.ts new file mode 100644 index 000000000..bfbe7fd82 --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/zrx/types.ts @@ -0,0 +1,37 @@ +type Fee = { + token?: string + amount?: string + amountUsd?: string +} + +export type TradesResponse = { + nextCursor?: string + trades: Array<{ + appName: string + blockNumber: string + buyToken: string + buyAmount?: string + chainId: number + chainName: string + fees: { + integratorFee?: Fee + zeroExFee?: Fee + } + gasUsed: string + protocolVersion: '0xv4' | 'Settler' + sellToken: string + sellAmount?: string + slippageBps?: string + taker: string + timestamp: number + tokens: Array<{ + address: string + symbol?: string + }> + transactionHash: string + volumeUsd?: string + zid: string + service: 'gasless' | 'swap' + }> + zid: string +} diff --git a/node/proxy/api/src/affiliateRevenue/zrx.ts b/node/proxy/api/src/affiliateRevenue/zrx/zrx.ts similarity index 51% rename from node/proxy/api/src/affiliateRevenue/zrx.ts rename to node/proxy/api/src/affiliateRevenue/zrx/zrx.ts index 9dc75800d..4b75237db 100644 --- a/node/proxy/api/src/affiliateRevenue/zrx.ts +++ b/node/proxy/api/src/affiliateRevenue/zrx/zrx.ts @@ -1,57 +1,17 @@ import axios from 'axios' -import { Fees } from '.' -import { NATIVE_TOKEN_ADDRESS, SLIP44 } from './constants' - -const ZRX_API_KEY = process.env.ZRX_API_KEY - -if (!ZRX_API_KEY) throw new Error('ZRX_API_KEY env var not set') - -type Fee = { - token?: string - amount?: string - amountUsd?: string -} - -type TradesResponse = { - nextCursor?: string - trades: Array<{ - appName: string - blockNumber: string - buyToken: string - buyAmount?: string - chainId: number - chainName: string - fees: { - integratorFee?: Fee - zeroExFee?: Fee - } - gasUsed: string - protocolVersion: '0xv4' | 'Settler' - sellToken: string - sellAmount?: string - slippageBps?: string - taker: string - timestamp: number - tokens: Array<{ - address: string - symbol?: string - }> - transactionHash: string - volumeUsd?: string - zid: string - service: 'gasless' | 'swap' - }> - zid: string -} +import { Fees } from '..' +import { SLIP44 } from '../constants' +import { NATIVE_TOKEN_ADDRESS, SERVICES, ZRX_API_KEY, ZRX_API_URL } from './constants' +import type { TradesResponse } from './types' export const getFees = async (startTimestamp: number, endTimestamp: number): Promise> => { const fees: Array = [] - for (const service of ['swap', 'gasless']) { + for (const service of SERVICES) { let cursor: string | undefined do { - const { data } = await axios.get(`https://api.0x.org/trade-analytics/${service}`, { + const { data } = await axios.get(`${ZRX_API_URL}/${service}`, { params: { cursor, startTimestamp, endTimestamp }, headers: { '0x-api-key': ZRX_API_KEY, diff --git a/node/proxy/sample.env b/node/proxy/sample.env index 666aef86a..e12ab43a4 100644 --- a/node/proxy/sample.env +++ b/node/proxy/sample.env @@ -6,4 +6,4 @@ ZERION_API_KEY= ZRX_API_KEY= PORTALS_API_KEY= BEBOP_API_KEY= -NEAR_INTENTS_API_KEY= \ No newline at end of file +NEAR_INTENTS_API_KEY= From 91bad4285fae5d5d4765a9a6e0cf016c933358d0 Mon Sep 17 00:00:00 2001 From: Jibles Date: Wed, 24 Dec 2025 04:36:09 +0530 Subject: [PATCH 2/2] feat: add cacheing layer and make granularity daily --- .gitignore | 3 +- node/proxy/api/package.json | 1 + .../api/src/affiliateRevenue/bebop/bebop.ts | 56 +++++- .../affiliateRevenue/butterswap/butterswap.ts | 49 ++--- node/proxy/api/src/affiliateRevenue/cache.ts | 124 +++++++++++++ .../affiliateRevenue/chainflip/chainflip.ts | 60 ++++++- .../api/src/affiliateRevenue/constants.ts | 14 ++ node/proxy/api/src/affiliateRevenue/index.ts | 29 ++- .../affiliateRevenue/mayachain/mayachain.ts | 80 +++++++-- .../affiliateRevenue/nearIntents/constants.ts | 30 +++- .../nearIntents/nearIntents.ts | 57 +++++- .../src/affiliateRevenue/nearIntents/utils.ts | 7 + .../src/affiliateRevenue/portals/constants.ts | 24 ++- .../src/affiliateRevenue/portals/portals.ts | 170 ++++++++++++------ .../api/src/affiliateRevenue/portals/utils.ts | 32 +++- .../api/src/affiliateRevenue/relay/relay.ts | 56 +++++- .../affiliateRevenue/thorchain/thorchain.ts | 80 +++++++-- .../proxy/api/src/affiliateRevenue/zrx/zrx.ts | 56 +++++- node/proxy/api/src/controller.ts | 28 ++- node/proxy/api/src/models.ts | 8 +- node/proxy/api/src/swagger.json | 55 ++++-- yarn.lock | 1 + 22 files changed, 853 insertions(+), 167 deletions(-) create mode 100644 node/proxy/api/src/affiliateRevenue/cache.ts diff --git a/.gitignore b/.gitignore index 6cbd6872d..43cdce2c4 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,5 @@ monitoring/my-kube-prometheus/manifests monitoring/my-kube-prometheus/alertmanager-config.yaml # misc -.DS_Store \ No newline at end of file +.DS_Store +docker-compose.override.yml diff --git a/node/proxy/api/package.json b/node/proxy/api/package.json index a8ffd83c5..81be748d7 100644 --- a/node/proxy/api/package.json +++ b/node/proxy/api/package.json @@ -15,6 +15,7 @@ "@shapeshiftoss/common-api": "^10.0.0", "bottleneck": "^2.19.5", "elliptic-sdk": "^0.7.2", + "lru-cache": "^10.2.0", "viem": "^2.33.2" } } diff --git a/node/proxy/api/src/affiliateRevenue/bebop/bebop.ts b/node/proxy/api/src/affiliateRevenue/bebop/bebop.ts index 9a6142c10..807933880 100644 --- a/node/proxy/api/src/affiliateRevenue/bebop/bebop.ts +++ b/node/proxy/api/src/affiliateRevenue/bebop/bebop.ts @@ -1,11 +1,20 @@ import axios from 'axios' import { Fees } from '..' +import { + getCacheableThreshold, + getDateEndTimestamp, + getDateStartTimestamp, + groupFeesByDate, + saveCachedFees, + splitDateRange, + tryGetCachedFees, +} from '../cache' import { SLIP44 } from '../constants' import { BEBOP_API_KEY, BEBOP_API_URL, FEE_BPS_DENOMINATOR, NANOSECONDS_PER_SECOND, SHAPESHIFT_REFERRER } from './constants' import type { TradesResponse } from './types' -export const getFees = async (startTimestamp: number, endTimestamp: number): Promise> => { - const fees: Array = [] +const fetchFeesFromAPI = async (startTimestamp: number, endTimestamp: number): Promise => { + const fees: Fees[] = [] const start = startTimestamp * NANOSECONDS_PER_SECOND const end = endTimestamp * NANOSECONDS_PER_SECOND @@ -35,3 +44,46 @@ export const getFees = async (startTimestamp: number, endTimestamp: number): Pro return fees } + +export const getFees = async (startTimestamp: number, endTimestamp: number): Promise => { + const threshold = getCacheableThreshold() + const { cacheableDates, recentStart } = splitDateRange(startTimestamp, endTimestamp, threshold) + + const cachedFees: Fees[] = [] + const datesToFetch: string[] = [] + let cacheHits = 0 + let cacheMisses = 0 + + for (const date of cacheableDates) { + const cached = tryGetCachedFees('bebop', 'all', date) + if (cached) { + cachedFees.push(...cached) + cacheHits++ + } else { + datesToFetch.push(date) + cacheMisses++ + } + } + + const newFees: Fees[] = [] + if (datesToFetch.length > 0) { + const fetchStart = getDateStartTimestamp(datesToFetch[0]) + const fetchEnd = getDateEndTimestamp(datesToFetch[datesToFetch.length - 1]) + const fetched = await fetchFeesFromAPI(fetchStart, fetchEnd) + + const feesByDate = groupFeesByDate(fetched) + for (const date of datesToFetch) { + saveCachedFees('bebop', 'all', date, feesByDate[date] || []) + } + newFees.push(...fetched) + } + + const recentFees: Fees[] = [] + if (recentStart !== null) { + recentFees.push(...(await fetchFeesFromAPI(recentStart, endTimestamp))) + } + + console.log(`[bebop] Cache stats: ${cacheHits} hits, ${cacheMisses} misses`) + + return [...cachedFees, ...newFees, ...recentFees] +} diff --git a/node/proxy/api/src/affiliateRevenue/butterswap/butterswap.ts b/node/proxy/api/src/affiliateRevenue/butterswap/butterswap.ts index d11d30930..e2526ef30 100644 --- a/node/proxy/api/src/affiliateRevenue/butterswap/butterswap.ts +++ b/node/proxy/api/src/affiliateRevenue/butterswap/butterswap.ts @@ -1,5 +1,7 @@ +import { createHash } from 'crypto' import { encodeAbiParameters, parseAbiParameters } from 'viem' import { Fees } from '..' +import { getDateRange, getDateStartTimestamp } from '../cache' import { API_SUCCESS_CODE, BUTTERSWAP_AFFILIATE_ID, @@ -63,6 +65,10 @@ const getTotalBalance = async (blockNumber: number, tokens: string[]): Promise { + return '0x' + createHash('sha256').update(`${service}-${date}`).digest('hex') +} + export const getFees = async (startTimestamp: number, endTimestamp: number): Promise> => { const tokens = await fetchTokenList() @@ -72,18 +78,13 @@ export const getFees = async (startTimestamp: number, endTimestamp: number): Pro const startBlock = estimateBlockFromTimestamp(currentBlock, now, startTimestamp) const endBlock = estimateBlockFromTimestamp(currentBlock, now, endTimestamp) - let balanceAtStart: bigint - let balanceAtEnd: bigint - - try { - ;[balanceAtStart, balanceAtEnd] = await Promise.all([ - getTotalBalance(startBlock, tokens), - getTotalBalance(endBlock, tokens), - ]) - } catch (error) { + const [balanceAtStart, balanceAtEnd] = await Promise.all([ + getTotalBalance(startBlock, tokens), + getTotalBalance(endBlock, tokens), + ]).catch(error => { const message = error instanceof Error ? error.message : String(error) throw new Error(`Failed to query ButterSwap balance: ${message}`) - } + }) const feesForPeriod = balanceAtEnd - balanceAtStart @@ -91,17 +92,19 @@ export const getFees = async (startTimestamp: number, endTimestamp: number): Pro return [] } - const feesUsd = Number(feesForPeriod) / 10 ** USDT_DECIMALS - - return [ - { - service: 'butterswap', - amount: feesForPeriod.toString(), - amountUsd: feesUsd.toString(), - chainId: MAP_CHAIN_ID, - assetId: `${MAP_CHAIN_ID}/erc20:${MAP_USDT_ADDRESS}`, - timestamp: endTimestamp, - txHash: '', - }, - ] + const dates = getDateRange(startTimestamp, endTimestamp) + const numDays = dates.length + + const feesPerDay = feesForPeriod / BigInt(numDays) + const feesPerDayUsd = Number(feesPerDay) / 10 ** USDT_DECIMALS + + return dates.map((date) => ({ + service: 'butterswap', + amount: feesPerDay.toString(), + amountUsd: feesPerDayUsd.toString(), + chainId: MAP_CHAIN_ID, + assetId: `${MAP_CHAIN_ID}/erc20:${MAP_USDT_ADDRESS}`, + timestamp: getDateStartTimestamp(date), + txHash: generateSyntheticTxHash('butterswap', date), + })) } diff --git a/node/proxy/api/src/affiliateRevenue/cache.ts b/node/proxy/api/src/affiliateRevenue/cache.ts new file mode 100644 index 000000000..26af5eaa3 --- /dev/null +++ b/node/proxy/api/src/affiliateRevenue/cache.ts @@ -0,0 +1,124 @@ +import { LRUCache } from 'lru-cache' +import type { Fees } from './index' +import type { TokenTransfer } from './portals/types' + +export const feeCache = new LRUCache({ + max: 5000, + maxSize: 500_000_000, + sizeCalculation: (fees) => fees.length * 200 + 100, + ttl: 1000 * 60 * 60 * 24 * 90, + updateAgeOnGet: true, + updateAgeOnHas: false, +}) + +export const tokenTransferCache = new LRUCache({ + max: 500, + ttl: 1000 * 60 * 60 * 24 * 7, +}) + +export const decimalsCache = new LRUCache({ + max: 1000, + ttl: 1000 * 60 * 60 * 24 * 90, +}) + +export const timestampToDate = (timestamp: number): string => { + const date = new Date(timestamp * 1000) + return date.toISOString().split('T')[0] +} + +export const getDateRange = (startTimestamp: number, endTimestamp: number): string[] => { + const dates: string[] = [] + const start = new Date(startTimestamp * 1000) + const end = new Date(endTimestamp * 1000) + + start.setUTCHours(0, 0, 0, 0) + end.setUTCHours(0, 0, 0, 0) + + const current = new Date(start) + while (current <= end) { + dates.push(current.toISOString().split('T')[0]) + current.setUTCDate(current.getUTCDate() + 1) + } + + return dates +} + +export const getDateStartTimestamp = (date: string): number => { + return Math.floor(new Date(date + 'T00:00:00Z').getTime() / 1000) +} + +export const getDateEndTimestamp = (date: string): number => { + return Math.floor(new Date(date + 'T23:59:59Z').getTime() / 1000) +} + +export const getCacheableThreshold = (): number => { + const today = new Date() + today.setUTCHours(0, 0, 0, 0) + return Math.floor(today.getTime() / 1000) +} + +export const getCacheKey = (service: string, chainId: string, date: string): string => { + return `${service}:${chainId}:${date}` +} + +export const tryGetCachedFees = (service: string, chainId: string, date: string): Fees[] | undefined => { + const key = getCacheKey(service, chainId, date) + return feeCache.get(key) +} + +export const saveCachedFees = (service: string, chainId: string, date: string, fees: Fees[]): void => { + const key = getCacheKey(service, chainId, date) + feeCache.set(key, fees) +} + +export const splitDateRange = ( + startTimestamp: number, + endTimestamp: number, + cacheableThreshold: number +): { cacheableDates: string[]; recentStart: number | null } => { + const allDates = getDateRange(startTimestamp, endTimestamp) + const cacheableDates: string[] = [] + let recentStart: number | null = null + + for (const date of allDates) { + const dateEnd = getDateEndTimestamp(date) + if (dateEnd < cacheableThreshold) { + cacheableDates.push(date) + } else if (recentStart === null) { + recentStart = Math.max(startTimestamp, cacheableThreshold) + } + } + + return { cacheableDates, recentStart } +} + +export const groupFeesByDate = (fees: Fees[]): Record => { + const feesByDate: Record = {} + + for (const fee of fees) { + const date = timestampToDate(fee.timestamp) + if (!feesByDate[date]) { + feesByDate[date] = [] + } + feesByDate[date].push(fee) + } + + return feesByDate +} + +export const getCachedTokenTransfer = (key: string): TokenTransfer | null | undefined => { + const cached = tokenTransferCache.get(key) + return cached ? cached.transfer : undefined +} + +export const saveCachedTokenTransfer = (key: string, transfer: TokenTransfer | null): void => { + tokenTransferCache.set(key, { transfer }) +} + +export const getCachedDecimals = (key: string): number | undefined => { + return decimalsCache.get(key) +} + +export const saveCachedDecimals = (key: string, decimals: number): void => { + decimalsCache.set(key, decimals) +} diff --git a/node/proxy/api/src/affiliateRevenue/chainflip/chainflip.ts b/node/proxy/api/src/affiliateRevenue/chainflip/chainflip.ts index 31b94df16..d9cedea66 100644 --- a/node/proxy/api/src/affiliateRevenue/chainflip/chainflip.ts +++ b/node/proxy/api/src/affiliateRevenue/chainflip/chainflip.ts @@ -1,11 +1,20 @@ import axios from 'axios' import { Fees } from '..' +import { + getCacheableThreshold, + getDateEndTimestamp, + getDateStartTimestamp, + groupFeesByDate, + saveCachedFees, + splitDateRange, + tryGetCachedFees, +} from '../cache' +import { ETHEREUM_CHAIN_ID } from '../constants' import { CHAINFLIP_API_URL, GET_AFFILIATE_SWAPS_QUERY, PAGE_SIZE, SHAPESHIFT_BROKER_ID } from './constants' import type { GraphQLResponse } from './types' -export const getFees = async (startTimestamp: number, endTimestamp: number): Promise> => { - const fees: Array = [] - +const fetchFeesFromAPI = async (startTimestamp: number, endTimestamp: number): Promise => { + const fees: Fees[] = [] const startDate = new Date(startTimestamp * 1000).toISOString() const endDate = new Date(endTimestamp * 1000).toISOString() @@ -29,7 +38,7 @@ export const getFees = async (startTimestamp: number, endTimestamp: number): Pro for (const { node: swap } of edges) { if (!swap.affiliateBroker1FeeValueUsd) continue - const chainId = 'eip155:1' + const chainId = ETHEREUM_CHAIN_ID const assetId = `${chainId}/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48` fees.push({ @@ -49,3 +58,46 @@ export const getFees = async (startTimestamp: number, endTimestamp: number): Pro return fees } + +export const getFees = async (startTimestamp: number, endTimestamp: number): Promise => { + const threshold = getCacheableThreshold() + const { cacheableDates, recentStart } = splitDateRange(startTimestamp, endTimestamp, threshold) + + const cachedFees: Fees[] = [] + const datesToFetch: string[] = [] + let cacheHits = 0 + let cacheMisses = 0 + + for (const date of cacheableDates) { + const cached = tryGetCachedFees('chainflip', ETHEREUM_CHAIN_ID, date) + if (cached) { + cachedFees.push(...cached) + cacheHits++ + } else { + datesToFetch.push(date) + cacheMisses++ + } + } + + const newFees: Fees[] = [] + if (datesToFetch.length > 0) { + const fetchStart = getDateStartTimestamp(datesToFetch[0]) + const fetchEnd = getDateEndTimestamp(datesToFetch[datesToFetch.length - 1]) + const fetched = await fetchFeesFromAPI(fetchStart, fetchEnd) + + const feesByDate = groupFeesByDate(fetched) + for (const date of datesToFetch) { + saveCachedFees('chainflip', ETHEREUM_CHAIN_ID, date, feesByDate[date] || []) + } + newFees.push(...fetched) + } + + const recentFees: Fees[] = [] + if (recentStart !== null) { + recentFees.push(...(await fetchFeesFromAPI(recentStart, endTimestamp))) + } + + console.log(`[chainflip] Cache stats: ${cacheHits} hits, ${cacheMisses} misses`) + + return [...cachedFees, ...newFees, ...recentFees] +} diff --git a/node/proxy/api/src/affiliateRevenue/constants.ts b/node/proxy/api/src/affiliateRevenue/constants.ts index ac731d545..e1352b759 100644 --- a/node/proxy/api/src/affiliateRevenue/constants.ts +++ b/node/proxy/api/src/affiliateRevenue/constants.ts @@ -17,7 +17,19 @@ export const TRON_CHAIN_ID = 'tron:0x2b6653dc' export const SUI_CHAIN_ID = 'sui:35834a8a' export const THORCHAIN_CHAIN_ID = 'cosmos:thorchain-1' export const MAYACHAIN_CHAIN_ID = 'cosmos:mayachain-mainnet-v1' +export const NEAR_CHAIN_ID = 'near:mainnet' +export const STARKNET_CHAIN_ID = 'starknet:SN_MAIN' + +// EVM Chain IDs (CAIP-2 format) +export const ETHEREUM_CHAIN_ID = 'eip155:1' +export const OPTIMISM_CHAIN_ID = 'eip155:10' +export const BSC_CHAIN_ID = 'eip155:56' +export const GNOSIS_CHAIN_ID = 'eip155:100' +export const POLYGON_CHAIN_ID = 'eip155:137' +export const BASE_CHAIN_ID = 'eip155:8453' export const MAP_CHAIN_ID = 'eip155:22776' +export const ARBITRUM_CHAIN_ID = 'eip155:42161' +export const AVALANCHE_CHAIN_ID = 'eip155:43114' // ButterSwap on MAP Protocol export const BUTTERSWAP_CONTRACT = '0x4De2ADb9cB88c10Bf200F76c18035cbB8906b6bC' @@ -32,10 +44,12 @@ export const SLIP44 = { ETHEREUM: 60, ZCASH: 133, TRON: 195, + NEAR: 397, SOLANA: 501, SUI: 784, THORCHAIN: 931, MAYACHAIN: 931, + STARKNET: 9004, } as const // Portals.fi - PortalsMulticall sends fee tokens to treasury after each swap diff --git a/node/proxy/api/src/affiliateRevenue/index.ts b/node/proxy/api/src/affiliateRevenue/index.ts index 9aff1d1e3..04e9e1b34 100644 --- a/node/proxy/api/src/affiliateRevenue/index.ts +++ b/node/proxy/api/src/affiliateRevenue/index.ts @@ -1,6 +1,7 @@ import axios from 'axios' import * as bebop from './bebop' import * as butterswap from './butterswap' +import { timestampToDate } from './cache' import * as chainflip from './chainflip' import * as mayachain from './mayachain' import * as nearintents from './nearIntents' @@ -72,21 +73,37 @@ export class AffiliateRevenue { } }) - const byService: Record = {} as Record + const byDate: AffiliateRevenueResponse['byDate'] = {} - for (const service of services) { - byService[service] = 0 + for (const fee of fees) { + const date = timestampToDate(fee.timestamp) + + if (!byDate[date]) { + byDate[date] = { + totalUsd: 0, + byService: Object.fromEntries(services.map((s) => [s, 0])) as Record, + } + } + + const amountUsd = parseFloat(fee.amountUsd || '0') + byDate[date].totalUsd += amountUsd + byDate[date].byService[fee.service] += amountUsd } - for (const revenue of fees) { - byService[revenue.service] = (byService[revenue.service] || 0) + parseFloat(revenue.amountUsd || '0') + const byService = Object.fromEntries(services.map((s) => [s, 0])) as Record + + for (const daily of Object.values(byDate)) { + for (const service of services) { + byService[service] += daily.byService[service] + } } - const totalUsd = fees.reduce((sum, rev) => sum + parseFloat(rev.amountUsd || '0'), 0) + const totalUsd = Object.values(byDate).reduce((sum, daily) => sum + daily.totalUsd, 0) return { totalUsd, byService, + byDate, failedProviders, } } diff --git a/node/proxy/api/src/affiliateRevenue/mayachain/mayachain.ts b/node/proxy/api/src/affiliateRevenue/mayachain/mayachain.ts index ef6c76994..ef721038a 100644 --- a/node/proxy/api/src/affiliateRevenue/mayachain/mayachain.ts +++ b/node/proxy/api/src/affiliateRevenue/mayachain/mayachain.ts @@ -1,5 +1,14 @@ import axios from 'axios' import { Fees } from '..' +import { + getCacheableThreshold, + getDateEndTimestamp, + getDateStartTimestamp, + groupFeesByDate, + saveCachedFees, + splitDateRange, + tryGetCachedFees, +} from '../cache' import { MAYACHAIN_CHAIN_ID, SLIP44 } from '../constants' import { CACAO_DECIMALS, MAYACHAIN_API_URL, MILLISECONDS_PER_SECOND, PRICE_API_URL } from './constants' import type { FeesResponse } from './types' @@ -15,9 +24,22 @@ const getCacaoPriceUsd = async (): Promise => { return Number(data.cacao.usd) } -export const getFees = async (startTimestamp: number, endTimestamp: number): Promise> => { - const revenues: Array = [] +const transformFee = (fee: FeesResponse['fees'][0], cacaoPriceUsd: number): Fees => { + const chainId = MAYACHAIN_CHAIN_ID + const assetId = `${chainId}/slip44:${SLIP44.MAYACHAIN}` + + return { + chainId, + assetId, + service: 'mayachain', + txHash: fee.txId, + timestamp: Math.round(fee.timestamp / 1000), + amount: fee.amount, + amountUsd: ((Number(fee.amount) / 10 ** CACAO_DECIMALS) * cacaoPriceUsd).toString(), + } +} +const fetchFeesFromAPI = async (startTimestamp: number, endTimestamp: number): Promise => { const start = startTimestamp * MILLISECONDS_PER_SECOND const end = endTimestamp * MILLISECONDS_PER_SECOND @@ -27,20 +49,48 @@ export const getFees = async (startTimestamp: number, endTimestamp: number): Pro const cacaoPriceUsd = await getCacaoPriceUsd() - const chainId = MAYACHAIN_CHAIN_ID - const assetId = `${chainId}/slip44:${SLIP44.MAYACHAIN}` + return data.fees.map(fee => transformFee(fee, cacaoPriceUsd)) +} - for (const fee of data.fees) { - revenues.push({ - chainId, - assetId, - service: 'mayachain', - txHash: fee.txId, - timestamp: Math.round(fee.timestamp / 1000), - amount: fee.amount, - amountUsd: ((Number(fee.amount) / 10 ** CACAO_DECIMALS) * cacaoPriceUsd).toString(), - }) +export const getFees = async (startTimestamp: number, endTimestamp: number): Promise => { + const threshold = getCacheableThreshold() + const { cacheableDates, recentStart } = splitDateRange(startTimestamp, endTimestamp, threshold) + + const cachedFees: Fees[] = [] + const datesToFetch: string[] = [] + let cacheHits = 0 + let cacheMisses = 0 + + for (const date of cacheableDates) { + const cached = tryGetCachedFees('mayachain', MAYACHAIN_CHAIN_ID, date) + if (cached) { + cachedFees.push(...cached) + cacheHits++ + } else { + datesToFetch.push(date) + cacheMisses++ + } + } + + const newFees: Fees[] = [] + if (datesToFetch.length > 0) { + const fetchStart = getDateStartTimestamp(datesToFetch[0]) + const fetchEnd = getDateEndTimestamp(datesToFetch[datesToFetch.length - 1]) + const fetched = await fetchFeesFromAPI(fetchStart, fetchEnd) + + const feesByDate = groupFeesByDate(fetched) + for (const date of datesToFetch) { + saveCachedFees('mayachain', MAYACHAIN_CHAIN_ID, date, feesByDate[date] || []) + } + newFees.push(...fetched) + } + + const recentFees: Fees[] = [] + if (recentStart !== null) { + recentFees.push(...(await fetchFeesFromAPI(recentStart, endTimestamp))) } - return revenues + console.log(`[mayachain] Cache stats: ${cacheHits} hits, ${cacheMisses} misses`) + + return [...cachedFees, ...newFees, ...recentFees] } diff --git a/node/proxy/api/src/affiliateRevenue/nearIntents/constants.ts b/node/proxy/api/src/affiliateRevenue/nearIntents/constants.ts index 423743b08..e4069c980 100644 --- a/node/proxy/api/src/affiliateRevenue/nearIntents/constants.ts +++ b/node/proxy/api/src/affiliateRevenue/nearIntents/constants.ts @@ -1,8 +1,18 @@ import { + ARBITRUM_CHAIN_ID, + AVALANCHE_CHAIN_ID, + BASE_CHAIN_ID, BITCOIN_CHAIN_ID, + BSC_CHAIN_ID, DOGECOIN_CHAIN_ID, + ETHEREUM_CHAIN_ID, + GNOSIS_CHAIN_ID, + NEAR_CHAIN_ID, + OPTIMISM_CHAIN_ID, + POLYGON_CHAIN_ID, SLIP44, SOLANA_CHAIN_ID, + STARKNET_CHAIN_ID, SUI_CHAIN_ID, TRON_CHAIN_ID, ZCASH_CHAIN_ID, @@ -14,20 +24,22 @@ export const FEE_BPS_DENOMINATOR = 10000 if (!NEAR_INTENTS_API_KEY) throw new Error('NEAR_INTENTS_API_KEY env var not set') export const NEAR_INTENTS_TO_CHAIN_ID: Record = { - eth: 'eip155:1', - arb: 'eip155:42161', - base: 'eip155:8453', - gnosis: 'eip155:100', - bsc: 'eip155:56', - pol: 'eip155:137', - avax: 'eip155:43114', - op: 'eip155:10', + eth: ETHEREUM_CHAIN_ID, + arb: ARBITRUM_CHAIN_ID, + base: BASE_CHAIN_ID, + gnosis: GNOSIS_CHAIN_ID, + bsc: BSC_CHAIN_ID, + pol: POLYGON_CHAIN_ID, + avax: AVALANCHE_CHAIN_ID, + op: OPTIMISM_CHAIN_ID, btc: BITCOIN_CHAIN_ID, doge: DOGECOIN_CHAIN_ID, zec: ZCASH_CHAIN_ID, sol: SOLANA_CHAIN_ID, tron: TRON_CHAIN_ID, sui: SUI_CHAIN_ID, + near: NEAR_CHAIN_ID, + starknet: STARKNET_CHAIN_ID, monad: 'eip155:143', } @@ -35,7 +47,9 @@ export const SLIP44_BY_NETWORK: Record = { btc: SLIP44.BITCOIN, doge: SLIP44.DOGECOIN, zec: SLIP44.ZCASH, + near: SLIP44.NEAR, sol: SLIP44.SOLANA, tron: SLIP44.TRON, sui: SLIP44.SUI, + starknet: SLIP44.STARKNET, } diff --git a/node/proxy/api/src/affiliateRevenue/nearIntents/nearIntents.ts b/node/proxy/api/src/affiliateRevenue/nearIntents/nearIntents.ts index 6b4b7fef0..568c79aba 100644 --- a/node/proxy/api/src/affiliateRevenue/nearIntents/nearIntents.ts +++ b/node/proxy/api/src/affiliateRevenue/nearIntents/nearIntents.ts @@ -1,5 +1,14 @@ import axios from 'axios' import { Fees } from '..' +import { + getCacheableThreshold, + getDateEndTimestamp, + getDateStartTimestamp, + groupFeesByDate, + saveCachedFees, + splitDateRange, + tryGetCachedFees, +} from '../cache' import { FEE_BPS_DENOMINATOR, NEAR_INTENTS_API_KEY } from './constants' import type { TransactionsResponse } from './types' import { parseNearIntentsAsset, sleep } from './utils' @@ -36,9 +45,8 @@ const fetchPage = async ( } } -export const getFees = async (startTimestamp: number, endTimestamp: number): Promise> => { - const fees: Array = [] - +const fetchFeesFromAPI = async (startTimestamp: number, endTimestamp: number): Promise => { + const fees: Fees[] = [] let page: number | undefined = 1 while (page) { @@ -77,3 +85,46 @@ export const getFees = async (startTimestamp: number, endTimestamp: number): Pro return fees } + +export const getFees = async (startTimestamp: number, endTimestamp: number): Promise => { + const threshold = getCacheableThreshold() + const { cacheableDates, recentStart } = splitDateRange(startTimestamp, endTimestamp, threshold) + + const cachedFees: Fees[] = [] + const datesToFetch: string[] = [] + let cacheHits = 0 + let cacheMisses = 0 + + for (const date of cacheableDates) { + const cached = tryGetCachedFees('nearintents', 'all', date) + if (cached) { + cachedFees.push(...cached) + cacheHits++ + } else { + datesToFetch.push(date) + cacheMisses++ + } + } + + const newFees: Fees[] = [] + if (datesToFetch.length > 0) { + const fetchStart = getDateStartTimestamp(datesToFetch[0]) + const fetchEnd = getDateEndTimestamp(datesToFetch[datesToFetch.length - 1]) + const fetched = await fetchFeesFromAPI(fetchStart, fetchEnd) + + const feesByDate = groupFeesByDate(fetched) + for (const date of datesToFetch) { + saveCachedFees('nearintents', 'all', date, feesByDate[date] || []) + } + newFees.push(...fetched) + } + + const recentFees: Fees[] = [] + if (recentStart !== null) { + recentFees.push(...(await fetchFeesFromAPI(recentStart, endTimestamp))) + } + + console.log(`[nearintents] Cache stats: ${cacheHits} hits, ${cacheMisses} misses`) + + return [...cachedFees, ...newFees, ...recentFees] +} diff --git a/node/proxy/api/src/affiliateRevenue/nearIntents/utils.ts b/node/proxy/api/src/affiliateRevenue/nearIntents/utils.ts index 1c3afb0c3..c5ce11563 100644 --- a/node/proxy/api/src/affiliateRevenue/nearIntents/utils.ts +++ b/node/proxy/api/src/affiliateRevenue/nearIntents/utils.ts @@ -41,6 +41,13 @@ export const parseNearIntentsAsset = (asset: string): ParseResult => { return { chainId, assetId: buildAssetId(chainId, network) } } + const nep141NativeMatch = asset.match(/^nep141:(.+)\.near$/) + if (nep141NativeMatch) { + const tokenAddress = nep141NativeMatch[1] + const chainId = resolveChainId('near') ?? 'near:mainnet' + return { chainId, assetId: `${chainId}/nep141:${tokenAddress}` } + } + const nep245Match = asset.match(/^nep245:v2_1\.omni\.hot\.tg:(\d+)_.+$/) if (nep245Match) { const chainId = `eip155:${nep245Match[1]}` diff --git a/node/proxy/api/src/affiliateRevenue/portals/constants.ts b/node/proxy/api/src/affiliateRevenue/portals/constants.ts index 03052a118..725483628 100644 --- a/node/proxy/api/src/affiliateRevenue/portals/constants.ts +++ b/node/proxy/api/src/affiliateRevenue/portals/constants.ts @@ -1,4 +1,8 @@ import { + ARBITRUM_CHAIN_ID, + AVALANCHE_CHAIN_ID, + BASE_CHAIN_ID, + BSC_CHAIN_ID, DAO_TREASURY_ARBITRUM, DAO_TREASURY_AVALANCHE, DAO_TREASURY_BASE, @@ -7,6 +11,10 @@ import { DAO_TREASURY_GNOSIS, DAO_TREASURY_OPTIMISM, DAO_TREASURY_POLYGON, + ETHEREUM_CHAIN_ID, + GNOSIS_CHAIN_ID, + OPTIMISM_CHAIN_ID, + POLYGON_CHAIN_ID, } from '../constants' import type { ChainConfig } from './types' @@ -24,7 +32,7 @@ export const PORTAL_EVENT_ABI = [ export const CHAIN_CONFIGS: ChainConfig[] = [ { - chainId: 'eip155:1', + chainId: ETHEREUM_CHAIN_ID, network: 'ethereum', router: '0xbf5a7f3629fb325e2a8453d595ab103465f75e62', treasury: DAO_TREASURY_ETHEREUM, @@ -32,7 +40,7 @@ export const CHAIN_CONFIGS: ChainConfig[] = [ explorerUrl: 'https://eth.blockscout.com', }, { - chainId: 'eip155:42161', + chainId: ARBITRUM_CHAIN_ID, network: 'arbitrum', router: '0x34b6a821d2f26c6b7cdb01cd91895170c6574a0d', treasury: DAO_TREASURY_ARBITRUM, @@ -40,7 +48,7 @@ export const CHAIN_CONFIGS: ChainConfig[] = [ explorerUrl: 'https://arbitrum.blockscout.com', }, { - chainId: 'eip155:10', + chainId: OPTIMISM_CHAIN_ID, network: 'optimism', router: '0x43838f0c0d499f5c3101589f0f452b1fc7515178', treasury: DAO_TREASURY_OPTIMISM, @@ -48,7 +56,7 @@ export const CHAIN_CONFIGS: ChainConfig[] = [ explorerUrl: 'https://optimism.blockscout.com', }, { - chainId: 'eip155:8453', + chainId: BASE_CHAIN_ID, network: 'base', router: '0xb0324286b3ef7dddc93fb2ff7c8b7b8a3524803c', treasury: DAO_TREASURY_BASE, @@ -56,7 +64,7 @@ export const CHAIN_CONFIGS: ChainConfig[] = [ explorerUrl: 'https://base.blockscout.com', }, { - chainId: 'eip155:137', + chainId: POLYGON_CHAIN_ID, network: 'polygon', router: '0xC74063fdb47fe6dCE6d029A489BAb37b167Da57f', treasury: DAO_TREASURY_POLYGON, @@ -64,7 +72,7 @@ export const CHAIN_CONFIGS: ChainConfig[] = [ explorerUrl: 'https://polygon.blockscout.com', }, { - chainId: 'eip155:100', + chainId: GNOSIS_CHAIN_ID, network: 'gnosis', router: '0x8e74454b2cf2f6cc2a06083ef122187551cf391c', treasury: DAO_TREASURY_GNOSIS, @@ -72,7 +80,7 @@ export const CHAIN_CONFIGS: ChainConfig[] = [ explorerUrl: 'https://gnosis.blockscout.com', }, { - chainId: 'eip155:56', + chainId: BSC_CHAIN_ID, network: 'bsc', router: '0x34b6a821d2f26c6b7cdb01cd91895170c6574a0d', treasury: DAO_TREASURY_BSC, @@ -80,7 +88,7 @@ export const CHAIN_CONFIGS: ChainConfig[] = [ explorerUrl: 'https://api.bscscan.com', }, { - chainId: 'eip155:43114', + chainId: AVALANCHE_CHAIN_ID, network: 'avalanche', router: '0xbf5A7F3629fB325E2a8453D595AB103465F75E62', treasury: DAO_TREASURY_AVALANCHE, diff --git a/node/proxy/api/src/affiliateRevenue/portals/portals.ts b/node/proxy/api/src/affiliateRevenue/portals/portals.ts index c4aa4bc72..9187966e4 100644 --- a/node/proxy/api/src/affiliateRevenue/portals/portals.ts +++ b/node/proxy/api/src/affiliateRevenue/portals/portals.ts @@ -1,6 +1,17 @@ import axios from 'axios' import { padHex, zeroAddress } from 'viem' import { Fees } from '..' +import { + getCacheableThreshold, + getCachedTokenTransfer, + getDateEndTimestamp, + getDateStartTimestamp, + groupFeesByDate, + saveCachedFees, + saveCachedTokenTransfer, + splitDateRange, + tryGetCachedFees, +} from '../cache' import { CHAIN_CONFIGS, PORTAL_EVENT_SIGNATURE } from './constants' import type { BlockscoutLogsResponse, @@ -189,74 +200,123 @@ const getFeeTransferEtherscan = async (config: ChainConfig, txHash: string): Pro return null } -const getFeesForChain = async (config: ChainConfig, startTimestamp: number, endTimestamp: number): Promise => { - const fees: Fees[] = [] +const constructFeeFromEvent = async (config: ChainConfig, event: PortalEventData): Promise => { + try { + const cacheKey = `${config.chainId}:${event.txHash}` + const cached = getCachedTokenTransfer(cacheKey) + + const feeTransfer = + cached !== undefined + ? cached + : await (async () => { + try { + const transfer = + config.explorerType === 'blockscout' + ? await getFeeTransferBlockscout(config, event.txHash) + : await getFeeTransferEtherscan(config, event.txHash) + saveCachedTokenTransfer(cacheKey, transfer) + return transfer + } catch { + saveCachedTokenTransfer(cacheKey, null) + return null + } + })() + + if (feeTransfer) { + const assetId = buildAssetId(config.chainId, feeTransfer.token ?? zeroAddress) + const amountDecimal = Number(feeTransfer.amount) / 10 ** feeTransfer.decimals + const price = await getTokenPrice(config.chainId, feeTransfer.token ?? '') + const amountUsd = price ? (amountDecimal * price).toString() : undefined + + return { + chainId: config.chainId, + assetId, + service: 'portals', + txHash: event.txHash, + timestamp: event.timestamp, + amount: feeTransfer.amount, + amountUsd, + } + } else { + const inputToken = event.inputToken ?? zeroAddress + const assetId = buildAssetId(config.chainId, inputToken) + const decimals = await getTokenDecimals(config.explorerUrl, config.explorerType, inputToken) + const feeWei = calculateFallbackFee(event.inputAmount) + const feeDecimal = Number(feeWei) / 10 ** decimals + const price = await getTokenPrice(config.chainId, inputToken) + const amountUsd = price ? (feeDecimal * price).toString() : undefined + + return { + chainId: config.chainId, + assetId, + service: 'portals', + txHash: event.txHash, + timestamp: event.timestamp, + amount: feeWei, + amountUsd, + } + } + } catch { + return null + } +} +const fetchFeesForChain = async (config: ChainConfig, startTimestamp: number, endTimestamp: number): Promise => { const events = config.explorerType === 'blockscout' ? await getPortalEventsBlockscout(config, startTimestamp, endTimestamp) : await getPortalEventsEtherscan(config, startTimestamp, endTimestamp) - for (const event of events) { - try { - let feeTransfer: TokenTransfer | null = null - - try { - feeTransfer = - config.explorerType === 'blockscout' - ? await getFeeTransferBlockscout(config, event.txHash) - : await getFeeTransferEtherscan(config, event.txHash) - } catch { - // Fall through to fallback calculation - } + const feePromises = events.map(event => constructFeeFromEvent(config, event)) + const feeResults = await Promise.allSettled(feePromises) - if (feeTransfer) { - const assetId = buildAssetId(config.chainId, feeTransfer.token ?? zeroAddress) - const amountDecimal = Number(feeTransfer.amount) / 10 ** feeTransfer.decimals - const price = await getTokenPrice(config.chainId, feeTransfer.token ?? '') - const amountUsd = price ? (amountDecimal * price).toString() : undefined - - fees.push({ - chainId: config.chainId, - assetId, - service: 'portals', - txHash: event.txHash, - timestamp: event.timestamp, - amount: feeTransfer.amount, - amountUsd, - }) - } else { - const inputToken = event.inputToken ?? zeroAddress - const assetId = buildAssetId(config.chainId, inputToken) - const decimals = await getTokenDecimals(config.explorerUrl, config.explorerType, inputToken) - const feeWei = calculateFallbackFee(event.inputAmount) - const feeDecimal = Number(feeWei) / 10 ** decimals - const price = await getTokenPrice(config.chainId, inputToken) - const amountUsd = price ? (feeDecimal * price).toString() : undefined - - fees.push({ - chainId: config.chainId, - assetId, - service: 'portals', - txHash: event.txHash, - timestamp: event.timestamp, - amount: feeWei, - amountUsd, - }) - } - } catch { - // Skip failed transactions - } - } + const fees = feeResults + .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') + .map(r => r.value) + .filter((fee): fee is Fees => fee !== null) - return fees + return fees.sort((a, b) => b.timestamp - a.timestamp) } -export const getFees = async (startTimestamp: number, endTimestamp: number): Promise> => { +export const getFees = async (startTimestamp: number, endTimestamp: number): Promise => { const allFees: Fees[] = [] + const threshold = getCacheableThreshold() + const { cacheableDates, recentStart } = splitDateRange(startTimestamp, endTimestamp, threshold) const results = await Promise.allSettled( - CHAIN_CONFIGS.map((config) => getFeesForChain(config, startTimestamp, endTimestamp)) + CHAIN_CONFIGS.map(async (config) => { + const cachedFees: Fees[] = [] + const datesToFetch: string[] = [] + + for (const date of cacheableDates) { + const cached = tryGetCachedFees('portals', config.chainId, date) + if (cached) { + cachedFees.push(...cached) + } else { + datesToFetch.push(date) + } + } + + const newFees: Fees[] = [] + if (datesToFetch.length > 0) { + const fetchStart = getDateStartTimestamp(datesToFetch[0]) + const fetchEnd = getDateEndTimestamp(datesToFetch[datesToFetch.length - 1]) + const fetched = await fetchFeesForChain(config, fetchStart, fetchEnd) + + const feesByDate = groupFeesByDate(fetched) + for (const date of datesToFetch) { + saveCachedFees('portals', config.chainId, date, feesByDate[date] || []) + } + newFees.push(...fetched) + } + + const recentFees: Fees[] = [] + if (recentStart !== null) { + recentFees.push(...(await fetchFeesForChain(config, recentStart, endTimestamp))) + } + + return [...cachedFees, ...newFees, ...recentFees] + }) ) for (const result of results) { diff --git a/node/proxy/api/src/affiliateRevenue/portals/utils.ts b/node/proxy/api/src/affiliateRevenue/portals/utils.ts index 079d280ce..0325eb842 100644 --- a/node/proxy/api/src/affiliateRevenue/portals/utils.ts +++ b/node/proxy/api/src/affiliateRevenue/portals/utils.ts @@ -1,5 +1,6 @@ import axios from 'axios' import { decodeAbiParameters, zeroAddress } from 'viem' +import { getCachedDecimals, saveCachedDecimals } from '../cache' import { SLIP44 } from '../constants' import { AFFILIATE_FEE_BPS, COINGECKO_API_BASE, COINGECKO_CHAINS, FEE_BPS_DENOMINATOR, PORTAL_EVENT_ABI } from './constants' import type { BlockscoutTransaction, DecodedPortalEvent, ExplorerType } from './types' @@ -39,17 +40,26 @@ export const getTokenDecimals = async ( ): Promise => { if (tokenAddress.toLowerCase() === zeroAddress) return 18 + const cacheKey = `${explorerUrl}:${tokenAddress.toLowerCase()}` + const cached = getCachedDecimals(cacheKey) + if (cached !== undefined) return cached + try { if (explorerType === 'blockscout') { const { data } = await axios.get<{ decimals?: string }>(`${explorerUrl}/api/v2/tokens/${tokenAddress}`) - return parseInt(data.decimals ?? '18') + const decimals = parseInt(data.decimals ?? '18') + saveCachedDecimals(cacheKey, decimals) + return decimals } const { data } = await axios.get<{ result?: Array<{ divisor?: string }> }>(`${explorerUrl}/api`, { params: { module: 'token', action: 'tokeninfo', contractaddress: tokenAddress }, }) - return parseInt(data.result?.[0]?.divisor ?? '18') + const decimals = parseInt(data.result?.[0]?.divisor ?? '18') + saveCachedDecimals(cacheKey, decimals) + return decimals } catch { + saveCachedDecimals(cacheKey, 18) return 18 } } @@ -60,7 +70,16 @@ export const buildAssetId = (chainId: string, tokenAddress: string): string => { return isNative ? `${chainId}/slip44:${SLIP44.ETHEREUM}` : `${chainId}/erc20:${tokenLower}` } +const priceCache: Record = {} +const PRICE_CACHE_TTL = 1000 * 60 * 5 // 5 minutes + export const getTokenPrice = async (chainId: string, tokenAddress: string): Promise => { + const cacheKey = `${chainId}:${tokenAddress.toLowerCase()}` + const cached = priceCache[cacheKey] + if (cached && Date.now() - cached.timestamp < PRICE_CACHE_TTL) { + return cached.price + } + try { const networkId = chainId.split(':')[1] const chainConfig = COINGECKO_CHAINS[networkId] @@ -74,14 +93,19 @@ export const getTokenPrice = async (chainId: string, tokenAddress: string): Prom `${COINGECKO_API_BASE}/simple/price`, { params: { vs_currencies: 'usd', ids: chainConfig.nativeCoinId } } ) - return data[chainConfig.nativeCoinId]?.usd ?? null + const price = data[chainConfig.nativeCoinId]?.usd ?? null + priceCache[cacheKey] = { price, timestamp: Date.now() } + return price } const { data } = await axios.get<{ market_data?: { current_price?: { usd?: number } } }>( `${COINGECKO_API_BASE}/coins/${chainConfig.platform}/contract/${tokenLower}` ) - return data.market_data?.current_price?.usd ?? null + const price = data.market_data?.current_price?.usd ?? null + priceCache[cacheKey] = { price, timestamp: Date.now() } + return price } catch { + priceCache[cacheKey] = { price: null, timestamp: Date.now() } return null } } diff --git a/node/proxy/api/src/affiliateRevenue/relay/relay.ts b/node/proxy/api/src/affiliateRevenue/relay/relay.ts index 7760435a3..25e425218 100644 --- a/node/proxy/api/src/affiliateRevenue/relay/relay.ts +++ b/node/proxy/api/src/affiliateRevenue/relay/relay.ts @@ -1,12 +1,21 @@ import axios from 'axios' import { Fees } from '..' +import { + getCacheableThreshold, + getDateEndTimestamp, + getDateStartTimestamp, + groupFeesByDate, + saveCachedFees, + splitDateRange, + tryGetCachedFees, +} from '../cache' import { DAO_TREASURY_BASE } from '../constants' import { RELAY_API_URL, SHAPESHIFT_REFERRER } from './constants' import type { RelayResponse } from './types' import { buildAssetId, getChainConfig } from './utils' -export const getFees = async (startTimestamp: number, endTimestamp: number): Promise> => { - const fees: Array = [] +const fetchFeesFromAPI = async (startTimestamp: number, endTimestamp: number): Promise => { + const fees: Fees[] = [] let continuation: string | undefined do { @@ -57,3 +66,46 @@ export const getFees = async (startTimestamp: number, endTimestamp: number): Pro return fees } + +export const getFees = async (startTimestamp: number, endTimestamp: number): Promise => { + const threshold = getCacheableThreshold() + const { cacheableDates, recentStart } = splitDateRange(startTimestamp, endTimestamp, threshold) + + const cachedFees: Fees[] = [] + const datesToFetch: string[] = [] + let cacheHits = 0 + let cacheMisses = 0 + + for (const date of cacheableDates) { + const cached = tryGetCachedFees('relay', 'all', date) + if (cached) { + cachedFees.push(...cached) + cacheHits++ + } else { + datesToFetch.push(date) + cacheMisses++ + } + } + + const newFees: Fees[] = [] + if (datesToFetch.length > 0) { + const fetchStart = getDateStartTimestamp(datesToFetch[0]) + const fetchEnd = getDateEndTimestamp(datesToFetch[datesToFetch.length - 1]) + const fetched = await fetchFeesFromAPI(fetchStart, fetchEnd) + + const feesByDate = groupFeesByDate(fetched) + for (const date of datesToFetch) { + saveCachedFees('relay', 'all', date, feesByDate[date] || []) + } + newFees.push(...fetched) + } + + const recentFees: Fees[] = [] + if (recentStart !== null) { + recentFees.push(...(await fetchFeesFromAPI(recentStart, endTimestamp))) + } + + console.log(`[relay] Cache stats: ${cacheHits} hits, ${cacheMisses} misses`) + + return [...cachedFees, ...newFees, ...recentFees] +} diff --git a/node/proxy/api/src/affiliateRevenue/thorchain/thorchain.ts b/node/proxy/api/src/affiliateRevenue/thorchain/thorchain.ts index 0ecf0da3f..714fe646c 100644 --- a/node/proxy/api/src/affiliateRevenue/thorchain/thorchain.ts +++ b/node/proxy/api/src/affiliateRevenue/thorchain/thorchain.ts @@ -1,5 +1,14 @@ import axios from 'axios' import { Fees } from '..' +import { + getCacheableThreshold, + getDateEndTimestamp, + getDateStartTimestamp, + groupFeesByDate, + saveCachedFees, + splitDateRange, + tryGetCachedFees, +} from '../cache' import { SLIP44, THORCHAIN_CHAIN_ID } from '../constants' import { MILLISECONDS_PER_SECOND, PRICE_API_URL, RUNE_DECIMALS, THORCHAIN_API_URL } from './constants' import type { FeesResponse } from './types' @@ -15,9 +24,22 @@ const getRunePriceUsd = async (): Promise => { return Number(data.thorchain.usd) } -export const getFees = async (startTimestamp: number, endTimestamp: number): Promise> => { - const fees: Array = [] +const transformFee = (fee: FeesResponse['fees'][0], runePriceUsd: number): Fees => { + const chainId = THORCHAIN_CHAIN_ID + const assetId = `${chainId}/slip44:${SLIP44.THORCHAIN}` + + return { + chainId, + assetId, + service: 'thorchain', + txHash: fee.txId, + timestamp: Math.round(fee.timestamp / 1000), + amount: fee.amount, + amountUsd: ((Number(fee.amount) / 10 ** RUNE_DECIMALS) * runePriceUsd).toString(), + } +} +const fetchFeesFromAPI = async (startTimestamp: number, endTimestamp: number): Promise => { const start = startTimestamp * MILLISECONDS_PER_SECOND const end = endTimestamp * MILLISECONDS_PER_SECOND @@ -27,20 +49,48 @@ export const getFees = async (startTimestamp: number, endTimestamp: number): Pro const runePriceUsd = await getRunePriceUsd() - const chainId = THORCHAIN_CHAIN_ID - const assetId = `${chainId}/slip44:${SLIP44.THORCHAIN}` + return data.fees.map(fee => transformFee(fee, runePriceUsd)) +} - for (const fee of data.fees) { - fees.push({ - chainId, - assetId, - service: 'thorchain', - txHash: fee.txId, - timestamp: Math.round(fee.timestamp / 1000), - amount: fee.amount, - amountUsd: ((Number(fee.amount) / 10 ** RUNE_DECIMALS) * runePriceUsd).toString(), - }) +export const getFees = async (startTimestamp: number, endTimestamp: number): Promise => { + const threshold = getCacheableThreshold() + const { cacheableDates, recentStart } = splitDateRange(startTimestamp, endTimestamp, threshold) + + const cachedFees: Fees[] = [] + const datesToFetch: string[] = [] + let cacheHits = 0 + let cacheMisses = 0 + + for (const date of cacheableDates) { + const cached = tryGetCachedFees('thorchain', THORCHAIN_CHAIN_ID, date) + if (cached) { + cachedFees.push(...cached) + cacheHits++ + } else { + datesToFetch.push(date) + cacheMisses++ + } + } + + const newFees: Fees[] = [] + if (datesToFetch.length > 0) { + const fetchStart = getDateStartTimestamp(datesToFetch[0]) + const fetchEnd = getDateEndTimestamp(datesToFetch[datesToFetch.length - 1]) + const fetched = await fetchFeesFromAPI(fetchStart, fetchEnd) + + const feesByDate = groupFeesByDate(fetched) + for (const date of datesToFetch) { + saveCachedFees('thorchain', THORCHAIN_CHAIN_ID, date, feesByDate[date] || []) + } + newFees.push(...fetched) + } + + const recentFees: Fees[] = [] + if (recentStart !== null) { + recentFees.push(...(await fetchFeesFromAPI(recentStart, endTimestamp))) } - return fees + console.log(`[thorchain] Cache stats: ${cacheHits} hits, ${cacheMisses} misses`) + + return [...cachedFees, ...newFees, ...recentFees] } diff --git a/node/proxy/api/src/affiliateRevenue/zrx/zrx.ts b/node/proxy/api/src/affiliateRevenue/zrx/zrx.ts index 4b75237db..7eb5184fe 100644 --- a/node/proxy/api/src/affiliateRevenue/zrx/zrx.ts +++ b/node/proxy/api/src/affiliateRevenue/zrx/zrx.ts @@ -1,11 +1,20 @@ import axios from 'axios' import { Fees } from '..' +import { + getCacheableThreshold, + getDateEndTimestamp, + getDateStartTimestamp, + groupFeesByDate, + saveCachedFees, + splitDateRange, + tryGetCachedFees, +} from '../cache' import { SLIP44 } from '../constants' import { NATIVE_TOKEN_ADDRESS, SERVICES, ZRX_API_KEY, ZRX_API_URL } from './constants' import type { TradesResponse } from './types' -export const getFees = async (startTimestamp: number, endTimestamp: number): Promise> => { - const fees: Array = [] +const fetchFeesFromAPI = async (startTimestamp: number, endTimestamp: number): Promise => { + const fees: Fees[] = [] for (const service of SERVICES) { let cursor: string | undefined @@ -45,3 +54,46 @@ export const getFees = async (startTimestamp: number, endTimestamp: number): Pro return fees } + +export const getFees = async (startTimestamp: number, endTimestamp: number): Promise => { + const threshold = getCacheableThreshold() + const { cacheableDates, recentStart } = splitDateRange(startTimestamp, endTimestamp, threshold) + + const cachedFees: Fees[] = [] + const datesToFetch: string[] = [] + let cacheHits = 0 + let cacheMisses = 0 + + for (const date of cacheableDates) { + const cached = tryGetCachedFees('zrx', 'all', date) + if (cached) { + cachedFees.push(...cached) + cacheHits++ + } else { + datesToFetch.push(date) + cacheMisses++ + } + } + + const newFees: Fees[] = [] + if (datesToFetch.length > 0) { + const fetchStart = getDateStartTimestamp(datesToFetch[0]) + const fetchEnd = getDateEndTimestamp(datesToFetch[datesToFetch.length - 1]) + const fetched = await fetchFeesFromAPI(fetchStart, fetchEnd) + + const feesByDate = groupFeesByDate(fetched) + for (const date of datesToFetch) { + saveCachedFees('zrx', 'all', date, feesByDate[date] || []) + } + newFees.push(...fetched) + } + + const recentFees: Fees[] = [] + if (recentStart !== null) { + recentFees.push(...(await fetchFeesFromAPI(recentStart, endTimestamp))) + } + + console.log(`[zrx] Cache stats: ${cacheHits} hits, ${cacheMisses} misses`) + + return [...cachedFees, ...newFees, ...recentFees] +} diff --git a/node/proxy/api/src/controller.ts b/node/proxy/api/src/controller.ts index af5f35d44..815862195 100644 --- a/node/proxy/api/src/controller.ts +++ b/node/proxy/api/src/controller.ts @@ -34,8 +34,8 @@ export class Proxy extends Controller { /** * Get affiliate revenue * - * @param {number} startTimestamp start timestamp (unix seconds) - * @param {number} endTimestamp end timestamp (unix seconds) + * @param {string} startDate start date (YYYY-MM-DD) + * @param {string} endDate end date (YYYY-MM-DD) * * @returns {Promise} affiliate revenue */ @@ -45,10 +45,30 @@ export class Proxy extends Controller { @Tags('Affiliate Revenue') @Get('/affiliate/revenue') async getAffiliateRevenue( - @Query() startTimestamp: number, - @Query() endTimestamp: number + @Query() startDate: string, + @Query() endDate: string ): Promise { try { + // Validate date format (YYYY-MM-DD) + const dateRegex = /^\d{4}-\d{2}-\d{2}$/ + if (!dateRegex.test(startDate)) { + throw new Error('Invalid startDate format, expected YYYY-MM-DD') + } + if (!dateRegex.test(endDate)) { + throw new Error('Invalid endDate format, expected YYYY-MM-DD') + } + + // Validate dates are valid calendar dates + const startTimestamp = Math.floor(new Date(`${startDate}T00:00:00Z`).getTime() / 1000) + const endTimestamp = Math.floor(new Date(`${endDate}T23:59:59Z`).getTime() / 1000) + + if (isNaN(startTimestamp)) { + throw new Error('Invalid startDate value') + } + if (isNaN(endTimestamp)) { + throw new Error('Invalid endDate value') + } + return await affiliateRevenue.getAffiliateRevenue(startTimestamp, endTimestamp) } catch (err) { throw handleError(err) diff --git a/node/proxy/api/src/models.ts b/node/proxy/api/src/models.ts index 5581b0dce..4ded807ed 100644 --- a/node/proxy/api/src/models.ts +++ b/node/proxy/api/src/models.ts @@ -15,8 +15,14 @@ export const services = [ ] as const export type Service = (typeof services)[number] +export interface DailyRevenue { + totalUsd: number + byService: Record +} + export interface AffiliateRevenueResponse { + totalUsd: number byService: Record + byDate: Record failedProviders: Service[] - totalUsd: number } diff --git a/node/proxy/api/src/swagger.json b/node/proxy/api/src/swagger.json index 530afd467..cf17022b6 100644 --- a/node/proxy/api/src/swagger.json +++ b/node/proxy/api/src/swagger.json @@ -121,6 +121,31 @@ "type": "object", "description": "Construct a type with a set of properties K of type T" }, + "DailyRevenue": { + "properties": { + "totalUsd": { + "type": "number", + "format": "double" + }, + "byService": { + "$ref": "#/components/schemas/Record_Service.number_" + } + }, + "required": [ + "totalUsd", + "byService" + ], + "type": "object", + "additionalProperties": false + }, + "Record_string.DailyRevenue_": { + "properties": {}, + "additionalProperties": { + "$ref": "#/components/schemas/DailyRevenue" + }, + "type": "object", + "description": "Construct a type with a set of properties K of type T" + }, "Service": { "type": "string", "enum": [ @@ -137,24 +162,28 @@ }, "AffiliateRevenueResponse": { "properties": { + "totalUsd": { + "type": "number", + "format": "double" + }, "byService": { "$ref": "#/components/schemas/Record_Service.number_" }, + "byDate": { + "$ref": "#/components/schemas/Record_string.DailyRevenue_" + }, "failedProviders": { "items": { "$ref": "#/components/schemas/Service" }, "type": "array" - }, - "totalUsd": { - "type": "number", - "format": "double" } }, "required": [ + "totalUsd", "byService", - "failedProviders", - "totalUsd" + "byDate", + "failedProviders" ], "type": "object", "additionalProperties": false @@ -293,23 +322,21 @@ "security": [], "parameters": [ { - "description": "start timestamp (unix seconds)", + "description": "start date (YYYY-MM-DD)", "in": "query", - "name": "startTimestamp", + "name": "startDate", "required": true, "schema": { - "format": "double", - "type": "number" + "type": "string" } }, { - "description": "end timestamp (unix seconds)", + "description": "end date (YYYY-MM-DD)", "in": "query", - "name": "endTimestamp", + "name": "endDate", "required": true, "schema": { - "format": "double", - "type": "number" + "type": "string" } } ] diff --git a/yarn.lock b/yarn.lock index 0809e0f89..301167798 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3361,6 +3361,7 @@ __metadata: "@shapeshiftoss/common-api": "npm:^10.0.0" bottleneck: "npm:^2.19.5" elliptic-sdk: "npm:^0.7.2" + lru-cache: "npm:^10.2.0" viem: "npm:^2.33.2" languageName: unknown linkType: soft