From 9fe147c20c78b9842ea04bfe14251478d0872cda Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:22:55 -0600 Subject: [PATCH 1/4] fix(mayachain): rewrite tracker on midgard, replacing unchained affiliate indexer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unchained affiliate fee indexer underreported drastically (124 fees all-time vs midgard's 379): it required the affiliate outbound and swap event in the same block (missing streaming swaps), its history was capped by the node's block-search index, and websocket gaps had no backfill. Fetch ssmaya actions from midgard instead, paging descending under the timestamp param only — mayachain midgard's fromTimestamp expects nanoseconds (unlike thorchain's seconds) and pages unstably when combined with timestamp, so the window's lower bound is enforced client-side. Price fees at historical CACAO spot derived from the USDC pool depth history (assetPriceUSD / assetPrice), replacing current-price enrichment — consistent with the thorchain tracker's /history/rune approach. Co-Authored-By: Claude Fable 5 --- .../affiliateRevenue/mayachain/constants.ts | 7 +- .../affiliateRevenue/mayachain/mayachain.ts | 132 +++++++++++++++--- .../src/affiliateRevenue/mayachain/types.ts | 60 ++++++-- 3 files changed, 163 insertions(+), 36 deletions(-) diff --git a/apps/revenue-api/src/affiliateRevenue/mayachain/constants.ts b/apps/revenue-api/src/affiliateRevenue/mayachain/constants.ts index 2217bf6..b5754a5 100644 --- a/apps/revenue-api/src/affiliateRevenue/mayachain/constants.ts +++ b/apps/revenue-api/src/affiliateRevenue/mayachain/constants.ts @@ -1,3 +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 MIDGARD_BASE_URL = 'https://api.mayachain.shapeshift.com/midgard/v2' +export const MIDGARD_AFFILIATE = 'ssmaya' +export const MIDGARD_PAGE_LIMIT = 50 +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..315c938 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,121 @@ 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 { 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) + if (!affiliateOut?.coins?.[0]) return acc + + const inTxId = action.in[0]?.txID + if (!inTxId) return acc + + const cacaoAmount = affiliateOut.coins[0].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 data.fees.map(transformFee) + return acc + }, []) } export const getFees = async (startTimestamp: number, endTimestamp: number): Promise => { @@ -67,7 +153,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 +164,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 +172,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[] } From 472fb7a5c65cdd5df53ccd3e5e99e5b57ea4c035 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:35:41 -0600 Subject: [PATCH 2/4] fix(thorchain): don't drop final-second fees at the midgard timestamp bound Midgard's timestamp param cuts at exactly endTimestamp*1e9 nanoseconds, so fees landing in the last second of a cacheable day were excluded and cached as absent. Pass endTimestamp+1 and trim to the window client-side, matching the mayachain tracker. Verified live on both midgards: timestamp=S excludes an action at S.xxx, timestamp=S+1 includes it, fromTimestamp is inclusive. Co-Authored-By: Claude Fable 5 --- .../src/affiliateRevenue/thorchain/thorchain.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/revenue-api/src/affiliateRevenue/thorchain/thorchain.ts b/apps/revenue-api/src/affiliateRevenue/thorchain/thorchain.ts index 7380be6..6220637 100644 --- a/apps/revenue-api/src/affiliateRevenue/thorchain/thorchain.ts +++ b/apps/revenue-api/src/affiliateRevenue/thorchain/thorchain.ts @@ -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 => { From 486e275d138fe655f90653a54666c59b616729f7 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:40:20 -0600 Subject: [PATCH 3/4] fix(mayachain): guard affiliate payouts to CACAO before applying CACAO pricing The fee record hardcodes CACAO decimals (1e10), the CACAO spot price, and the CACAO assetId, so a non-CACAO affiliate out would be serialized as a mispriced CACAO fee. Skip (and warn on) any non-CACAO payout instead. Co-Authored-By: Claude Fable 5 --- .../src/affiliateRevenue/mayachain/constants.ts | 1 + .../src/affiliateRevenue/mayachain/mayachain.ts | 12 +++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/revenue-api/src/affiliateRevenue/mayachain/constants.ts b/apps/revenue-api/src/affiliateRevenue/mayachain/constants.ts index b5754a5..29ed301 100644 --- a/apps/revenue-api/src/affiliateRevenue/mayachain/constants.ts +++ b/apps/revenue-api/src/affiliateRevenue/mayachain/constants.ts @@ -1,4 +1,5 @@ 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 315c938..437de57 100644 --- a/apps/revenue-api/src/affiliateRevenue/mayachain/mayachain.ts +++ b/apps/revenue-api/src/affiliateRevenue/mayachain/mayachain.ts @@ -14,7 +14,7 @@ import { MAYACHAIN_CHAIN_ID } from '../constants' import type { Fees } from '../types' import { buildAssetId } from '../utils' -import { MIDGARD_AFFILIATE, MIDGARD_BASE_URL, MIDGARD_PAGE_LIMIT, USDC_POOL } from './constants' +import { CACAO_ASSET, MIDGARD_AFFILIATE, MIDGARD_BASE_URL, MIDGARD_PAGE_LIMIT, USDC_POOL } from './constants' import type { DepthHistory, MidgardAction, MidgardActionsResponse } from './types' const selectInterval = (startTimestamp: number, endTimestamp: number): string => { @@ -102,12 +102,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 cacaoAmount = affiliateOut.coins[0].amount + if (affiliateCoin.asset !== CACAO_ASSET) { + console.warn(`[mayachain] Skipping non-CACAO affiliate payout ${affiliateCoin.asset} (tx ${inTxId})`) + return acc + } + + const cacaoAmount = affiliateCoin.amount const timestamp = actionTimestamp(action) const cacaoPrice = getCacaoPrice(timestamp) From 0af4a37fd47a1598dfff4fe055748bcc4c0accd0 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:48:16 -0600 Subject: [PATCH 4/4] fix(thorchain): guard affiliate payouts to RUNE before applying RUNE pricing Same latent issue as the mayachain tracker: the fee record hardcodes RUNE decimals (1e8), the RUNE spot price, and the RUNE assetId, so a non-RUNE affiliate out would be serialized as a mispriced RUNE fee. Skip (and warn on) any non-RUNE payout instead. Co-Authored-By: Claude Fable 5 --- .../src/affiliateRevenue/thorchain/constants.ts | 1 + .../src/affiliateRevenue/thorchain/thorchain.ts | 12 +++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) 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 6220637..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 => { @@ -89,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)