diff --git a/apps/revenue-api/src/affiliateRevenue/mayachain/constants.ts b/apps/revenue-api/src/affiliateRevenue/mayachain/constants.ts index 2217bf6..29ed301 100644 --- a/apps/revenue-api/src/affiliateRevenue/mayachain/constants.ts +++ b/apps/revenue-api/src/affiliateRevenue/mayachain/constants.ts @@ -1,3 +1,5 @@ -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 MIDGARD_BASE_URL = 'https://api.mayachain.shapeshift.com/midgard/v2' +export const MIDGARD_AFFILIATE = 'ssmaya' +export const MIDGARD_PAGE_LIMIT = 50 +export const CACAO_ASSET = 'MAYA.CACAO' +export const USDC_POOL = 'ETH.USDC-0XA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48' diff --git a/apps/revenue-api/src/affiliateRevenue/mayachain/mayachain.ts b/apps/revenue-api/src/affiliateRevenue/mayachain/mayachain.ts index e9cddcb..437de57 100644 --- a/apps/revenue-api/src/affiliateRevenue/mayachain/mayachain.ts +++ b/apps/revenue-api/src/affiliateRevenue/mayachain/mayachain.ts @@ -1,5 +1,6 @@ import axios from 'axios' +import { withRetry } from '../../utils/retry' import { getCacheableThreshold, getDateEndTimestamp, @@ -10,36 +11,127 @@ import { tryGetCachedFees, } from '../cache' import { MAYACHAIN_CHAIN_ID } from '../constants' -import { enrichFeesWithUsdPrices } from '../enrichment' import type { Fees } from '../types' import { buildAssetId } from '../utils' -import { MAYACHAIN_API_URL, MILLISECONDS_PER_SECOND } from './constants' -import type { FeesResponse } from './types' +import { CACAO_ASSET, MIDGARD_AFFILIATE, MIDGARD_BASE_URL, MIDGARD_PAGE_LIMIT, USDC_POOL } from './constants' +import type { DepthHistory, MidgardAction, MidgardActionsResponse } from './types' -const transformFee = (fee: FeesResponse['fees'][0]): Fees => { - const chainId = MAYACHAIN_CHAIN_ID - const assetId = buildAssetId(chainId) +const selectInterval = (startTimestamp: number, endTimestamp: number): string => { + const seconds = endTimestamp - startTimestamp + if (seconds <= 1_209_600) return 'hour' // up to 2 weeks + if (seconds <= 34_560_000) return 'day' // up to 400 days + if (seconds <= 94_608_000) return 'week' // up to 3 years + return 'month' +} - return { - chainId, - assetId, - service: 'mayachain', - txHash: fee.txId, - timestamp: Math.round(fee.timestamp / 1000), - amount: fee.amount, - } +// derive historical CACAO spot price from the USDC pool depth history: assetPriceUSD / assetPrice +// (assetPrice is the asset's price in CACAO, so dividing yields USD per CACAO) +const fetchCacaoPriceLookup = async ( + startTimestamp: number, + endTimestamp: number +): Promise<(timestamp: number) => number | undefined> => { + const interval = selectInterval(startTimestamp, endTimestamp) + + const { data } = await withRetry('mayachain/midgard-cacaoPrice', () => + axios.get(`${MIDGARD_BASE_URL}/history/depths/${USDC_POOL}`, { + params: { from: startTimestamp, to: endTimestamp, interval }, + }) + ) + + const intervals = (data.intervals ?? []) + .map(({ startTime, endTime, assetPrice, assetPriceUSD }) => ({ + startTime: Number(startTime), + endTime: Number(endTime), + priceUSD: Number(assetPrice) > 0 ? Number(assetPriceUSD) / Number(assetPrice) : 0, + })) + .filter(i => i.priceUSD > 0) + + return (timestamp: number): number | undefined => + (intervals.filter(i => i.startTime <= timestamp).at(-1) ?? intervals[0])?.priceUSD } -const fetchFeesFromAPI = async (startTimestamp: number, endTimestamp: number): Promise => { - const start = startTimestamp * MILLISECONDS_PER_SECOND - const end = endTimestamp * MILLISECONDS_PER_SECOND +const actionTimestamp = (action: MidgardAction): number => Math.floor(Number(action.date) / 1_000_000_000) + +// mayachain midgard's fromTimestamp param expects nanoseconds (unlike thorchain's seconds) and +// pages unstably when combined with timestamp, so page descending under timestamp (seconds) only +// and bound the lower edge of the window client-side. timestamp cuts at exactly endTimestamp*1e9 +// nanoseconds, which would drop final-second fees — pass endTimestamp+1 and let the client-side +// filter trim to the window +const fetchMidgardActions = async (startTimestamp: number, endTimestamp: number): Promise => { + const actions: MidgardAction[] = [] + + let offset = 0 + while (true) { + const { data } = await withRetry('mayachain/midgard-actions', () => + axios.get(`${MIDGARD_BASE_URL}/actions`, { + params: { + affiliate: MIDGARD_AFFILIATE, + timestamp: endTimestamp + 1, + limit: MIDGARD_PAGE_LIMIT, + offset, + }, + }) + ) + + const batch = data.actions ?? [] + actions.push(...batch) + + if (batch.length < MIDGARD_PAGE_LIMIT) break + + const oldest = batch[batch.length - 1] + if (actionTimestamp(oldest) < startTimestamp) break + + offset += MIDGARD_PAGE_LIMIT + } - const { data } = await axios.get(MAYACHAIN_API_URL, { - params: { start, end }, + return actions.filter(a => { + const ts = actionTimestamp(a) + return ts >= startTimestamp && ts <= endTimestamp }) +} + +const fetchFeesFromMidgard = async (startTimestamp: number, endTimestamp: number): Promise => { + const [getCacaoPrice, allActions] = await Promise.all([ + fetchCacaoPriceLookup(startTimestamp, endTimestamp), + fetchMidgardActions(startTimestamp, endTimestamp), + ]) + + const chainId = MAYACHAIN_CHAIN_ID + const assetId = buildAssetId(chainId) + + return allActions.reduce((acc, action) => { + const affiliateOut = action.out.find(o => o.affiliate === true) + const affiliateCoin = affiliateOut?.coins?.[0] + if (!affiliateCoin) return acc + + const inTxId = action.in[0]?.txID + if (!inTxId) return acc + + if (affiliateCoin.asset !== CACAO_ASSET) { + console.warn(`[mayachain] Skipping non-CACAO affiliate payout ${affiliateCoin.asset} (tx ${inTxId})`) + return acc + } - return data.fees.map(transformFee) + const cacaoAmount = affiliateCoin.amount + const timestamp = actionTimestamp(action) + + const cacaoPrice = getCacaoPrice(timestamp) + const amountUsd = cacaoPrice !== undefined ? ((Number(cacaoAmount) / 1e10) * cacaoPrice).toString() : undefined + + acc.push({ + chainId, + assetId, + service: 'mayachain', + txHash: inTxId, + timestamp, + amount: cacaoAmount, + amountUsd, + originalUsdValue: amountUsd, + }) + + return acc + }, []) } export const getFees = async (startTimestamp: number, endTimestamp: number): Promise => { @@ -67,7 +159,7 @@ export const getFees = async (startTimestamp: number, endTimestamp: number): Pro if (datesToFetch.length > 0) { const fetchStart = getDateStartTimestamp(datesToFetch[0]) const fetchEnd = getDateEndTimestamp(datesToFetch[datesToFetch.length - 1]) - const fetched = await fetchFeesFromAPI(fetchStart, fetchEnd) + const fetched = await fetchFeesFromMidgard(fetchStart, fetchEnd) const feesByDate = groupFeesByDate(fetched) for (const date of datesToFetch) { @@ -78,7 +170,7 @@ export const getFees = async (startTimestamp: number, endTimestamp: number): Pro const recentFees: Fees[] = [] if (recentStart !== null) { - recentFees.push(...(await fetchFeesFromAPI(recentStart, endTimestamp))) + recentFees.push(...(await fetchFeesFromMidgard(recentStart, endTimestamp))) } const totalFees = cachedFees.length + newFees.length + recentFees.length @@ -86,5 +178,5 @@ export const getFees = async (startTimestamp: number, endTimestamp: number): Pro console.log(`[mayachain] Total: ${totalFees} fees in ${duration}ms | Cache: ${cacheHits} hits, ${cacheMisses} misses`) - return enrichFeesWithUsdPrices([...cachedFees, ...newFees, ...recentFees]) + return [...cachedFees, ...newFees, ...recentFees] } diff --git a/apps/revenue-api/src/affiliateRevenue/mayachain/types.ts b/apps/revenue-api/src/affiliateRevenue/mayachain/types.ts index 9a745ea..c810c7d 100644 --- a/apps/revenue-api/src/affiliateRevenue/mayachain/types.ts +++ b/apps/revenue-api/src/affiliateRevenue/mayachain/types.ts @@ -1,11 +1,51 @@ -export type FeesResponse = { - fees: Array<{ - address: string - amount: string - asset: string - blockHash: string - blockHeight: number - timestamp: number - txId: string - }> +export type MidgardCoin = { + amount: string + asset: string +} + +export type MidgardTx = { + address: string + coins: MidgardCoin[] + txID: string + affiliate?: boolean + height?: string +} + +export type MidgardAction = { + date: string // nanoseconds as string + height: string + in: MidgardTx[] + out: MidgardTx[] + metadata: { + swap?: { + affiliateAddress: string + affiliateFee: string // bps as string e.g. "60" + inPriceUSD: string + outPriceUSD: string + memo: string + } + } + pools: string[] + status: string + type: string +} + +export type MidgardActionsResponse = { + actions: MidgardAction[] + meta: { nextPageToken?: string; prevPageToken?: string } +} + +export type DepthHistoryItem = { + startTime: string // unix seconds as string + endTime: string + assetPrice: string // asset price in CACAO + assetPriceUSD: string +} + +export type DepthHistory = { + meta: { + startTime: string + endTime: string + } + intervals: DepthHistoryItem[] } diff --git a/apps/revenue-api/src/affiliateRevenue/thorchain/constants.ts b/apps/revenue-api/src/affiliateRevenue/thorchain/constants.ts index 38e8d83..02e9dd8 100644 --- a/apps/revenue-api/src/affiliateRevenue/thorchain/constants.ts +++ b/apps/revenue-api/src/affiliateRevenue/thorchain/constants.ts @@ -3,4 +3,5 @@ import { THORCHAIN_CHAIN_ID, SLIP44 } from '../constants' export const MIDGARD_BASE_URL = 'https://api.thorchain.shapeshift.com/midgard/v2' export const MIDGARD_AFFILIATE = 'ss' export const MIDGARD_PAGE_LIMIT = 50 +export const RUNE_ASSET = 'THOR.RUNE' export const RUNE_ASSET_ID = `${THORCHAIN_CHAIN_ID}/slip44:${SLIP44.THORCHAIN}` diff --git a/apps/revenue-api/src/affiliateRevenue/thorchain/thorchain.ts b/apps/revenue-api/src/affiliateRevenue/thorchain/thorchain.ts index 7380be6..e885ca1 100644 --- a/apps/revenue-api/src/affiliateRevenue/thorchain/thorchain.ts +++ b/apps/revenue-api/src/affiliateRevenue/thorchain/thorchain.ts @@ -13,7 +13,7 @@ import { import { THORCHAIN_CHAIN_ID } from '../constants' import type { Fees } from '../types' -import { MIDGARD_AFFILIATE, MIDGARD_BASE_URL, MIDGARD_PAGE_LIMIT, RUNE_ASSET_ID } from './constants' +import { MIDGARD_AFFILIATE, MIDGARD_BASE_URL, MIDGARD_PAGE_LIMIT, RUNE_ASSET, RUNE_ASSET_ID } from './constants' import type { MidgardAction, MidgardActionsResponse, RunePriceHistory } from './types' const selectInterval = (startTimestamp: number, endTimestamp: number): string => { @@ -48,6 +48,8 @@ const fetchRunePriceLookup = async ( (intervals.filter(i => i.startTime <= timestamp).at(-1) ?? intervals[0])?.priceUSD } +// midgard's timestamp param cuts at exactly endTimestamp*1e9 nanoseconds, which would drop +// final-second fees — pass endTimestamp+1 and trim to the window client-side const fetchMidgardActions = async (startTimestamp: number, endTimestamp: number): Promise => { const actions: MidgardAction[] = [] @@ -58,7 +60,7 @@ const fetchMidgardActions = async (startTimestamp: number, endTimestamp: number) params: { affiliate: MIDGARD_AFFILIATE, fromTimestamp: startTimestamp, - timestamp: endTimestamp, + timestamp: endTimestamp + 1, limit: MIDGARD_PAGE_LIMIT, offset, }, @@ -72,7 +74,11 @@ const fetchMidgardActions = async (startTimestamp: number, endTimestamp: number) offset += MIDGARD_PAGE_LIMIT } - return actions + + return actions.filter(a => { + const ts = Math.floor(Number(a.date) / 1_000_000_000) + return ts >= startTimestamp && ts <= endTimestamp + }) } const fetchFeesFromMidgard = async (startTimestamp: number, endTimestamp: number): Promise => { @@ -83,12 +89,18 @@ const fetchFeesFromMidgard = async (startTimestamp: number, endTimestamp: number return allActions.reduce((acc, action) => { const affiliateOut = action.out.find(o => o.affiliate === true) - if (!affiliateOut?.coins?.[0]) return acc + const affiliateCoin = affiliateOut?.coins?.[0] + if (!affiliateCoin) return acc const inTxId = action.in[0]?.txID if (!inTxId) return acc - const runeAmount = affiliateOut.coins[0].amount + if (affiliateCoin.asset !== RUNE_ASSET) { + console.warn(`[thorchain] Skipping non-RUNE affiliate payout ${affiliateCoin.asset} (tx ${inTxId})`) + return acc + } + + const runeAmount = affiliateCoin.amount const timestamp = Math.floor(Number(action.date) / 1_000_000_000) const runePrice = getRunePrice(timestamp)