Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions apps/revenue-api/src/affiliateRevenue/mayachain/constants.ts
Original file line number Diff line number Diff line change
@@ -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'
138 changes: 115 additions & 23 deletions apps/revenue-api/src/affiliateRevenue/mayachain/mayachain.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import axios from 'axios'

import { withRetry } from '../../utils/retry'
import {
getCacheableThreshold,
getDateEndTimestamp,
Expand All @@ -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<DepthHistory>(`${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<Fees[]> => {
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<MidgardAction[]> => {
const actions: MidgardAction[] = []

let offset = 0
while (true) {
const { data } = await withRetry('mayachain/midgard-actions', () =>
axios.get<MidgardActionsResponse>(`${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<FeesResponse>(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<Fees[]> => {
const [getCacaoPrice, allActions] = await Promise.all([
fetchCacaoPriceLookup(startTimestamp, endTimestamp),
fetchMidgardActions(startTimestamp, endTimestamp),
])

const chainId = MAYACHAIN_CHAIN_ID
const assetId = buildAssetId(chainId)

return allActions.reduce<Fees[]>((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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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<Fees[]> => {
Expand Down Expand Up @@ -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) {
Expand All @@ -78,13 +170,13 @@ 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
const duration = Date.now() - startTime

console.log(`[mayachain] Total: ${totalFees} fees in ${duration}ms | Cache: ${cacheHits} hits, ${cacheMisses} misses`)

return enrichFeesWithUsdPrices([...cachedFees, ...newFees, ...recentFees])
return [...cachedFees, ...newFees, ...recentFees]
}
60 changes: 50 additions & 10 deletions apps/revenue-api/src/affiliateRevenue/mayachain/types.ts
Original file line number Diff line number Diff line change
@@ -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[]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
22 changes: 17 additions & 5 deletions apps/revenue-api/src/affiliateRevenue/thorchain/thorchain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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<MidgardAction[]> => {
const actions: MidgardAction[] = []

Expand All @@ -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,
},
Expand All @@ -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<Fees[]> => {
Expand All @@ -83,12 +89,18 @@ const fetchFeesFromMidgard = async (startTimestamp: number, endTimestamp: number

return allActions.reduce<Fees[]>((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)
Expand Down
Loading