From dd8effbad831c5a193de895bbec54c48d590049a Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:47:38 -0600 Subject: [PATCH 1/5] fix(swap-service): track Maya affiliate fee asset as CACAO not sell asset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MayaChain, like Thorchain, collects the affiliate fee in its native asset (CACAO), reported by the shared Midgard verifier as feeOut.coins[0].amount. The fee-asset strategy mapped Mayachain to 'sell_asset', so the stored affiliateFeeAssetId was the sell asset while the amount was CACAO base units. resolveActualFeeUsd then priced the CACAO amount with the sell asset's price and precision, producing wildly wrong USD fees (observed $39,020 on a $100 swap, which flowed into /stats and would have driven a ~$29k erroneous payout). Map Mayachain to mayachainAssetId, mirroring Thorchain's thorchainAssetId. New Maya swaps now fall back to the bps-implied fee (CACAO is neither sell nor buy, so precision is unknown) exactly like Thorchain — bounded and correct. Co-Authored-By: Claude Opus 4.8 --- apps/swap-service/src/utils/affiliateFeeAsset.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/swap-service/src/utils/affiliateFeeAsset.ts b/apps/swap-service/src/utils/affiliateFeeAsset.ts index 1a58d35..3a39ae6 100644 --- a/apps/swap-service/src/utils/affiliateFeeAsset.ts +++ b/apps/swap-service/src/utils/affiliateFeeAsset.ts @@ -1,5 +1,5 @@ import type { AssetId } from '@shapeshiftoss/caip' -import { thorchainAssetId } from '@shapeshiftoss/caip' +import { mayachainAssetId, thorchainAssetId } from '@shapeshiftoss/caip' import { SwapperName } from '@shapeshiftoss/swapper' import type { Asset } from '@shapeshiftoss/types' @@ -15,7 +15,7 @@ const SWAPPER_FEE_STRATEGY: Record = { [SwapperName.Chainflip]: 'buy_asset', [SwapperName.CowSwap]: 'sell_asset', [SwapperName.Debridge]: 'sell_asset', - [SwapperName.Mayachain]: 'sell_asset', + [SwapperName.Mayachain]: mayachainAssetId, [SwapperName.NearIntents]: 'sell_asset', [SwapperName.Portals]: 'sell_asset', [SwapperName.Relay]: null, From 82033f670673c13d8d35ee0a966a3e27987ec705 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:08:05 -0600 Subject: [PATCH 2/5] chore(scripts): backfill for historical Maya affiliate fee asset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drafted one-off backfill to correct pre-fix MayaChain swaps whose affiliateFeeAssetId was set to the sell asset instead of CACAO. For each swap it reads the Midgard action (block height + CACAO affiliate fee out), fetches the ETH.USDC pool at that height for historical CACAO/USD, and writes: affiliateFeeAssetId = CACAO actualAffiliateFeeAmountCryptoBaseUnit = Midgard 1e8 amount → CACAO native 1e10 affiliateAssetUsd = historical CACAO/USD Dry-run by default; --apply to write. Not yet applied. The backfilled affiliateAssetUsd/amount are correct but remain unused until resolveActualFeeUsd resolves the native fee-asset precision (deferred); until then Maya fees fall back to the bps-implied value, which is bounded and correct. Co-Authored-By: Claude Opus 4.8 --- package.json | 3 +- scripts/backfill-maya-fee-asset.ts | 178 +++++++++++++++++++++++++++++ tsconfig.json | 2 +- 3 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 scripts/backfill-maya-fee-asset.ts diff --git a/package.json b/package.json index f73a20e..6fd72dd 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "db:migrate:status": "prisma migrate status", "db:migrate:create": "prisma migrate dev --create-only --name", "db:studio": "prisma studio", - "referral-rewards": "ts-node scripts/referral-rewards.ts" + "referral-rewards": "ts-node scripts/referral-rewards.ts", + "backfill-maya-fee-asset": "ts-node --transpile-only scripts/backfill-maya-fee-asset.ts" }, "dependencies": { "@bitcoinerlab/secp256k1": "^1.1.1", diff --git a/scripts/backfill-maya-fee-asset.ts b/scripts/backfill-maya-fee-asset.ts new file mode 100644 index 0000000..634c325 --- /dev/null +++ b/scripts/backfill-maya-fee-asset.ts @@ -0,0 +1,178 @@ +/** + * One-off backfill: correct the affiliate fee asset for historical MayaChain swaps. + * + * Bug (fixed for new swaps in fix/mayachain-affiliate-fee-asset): Maya swaps were stored with + * `affiliateFeeAssetId` = the sell asset, but Maya collects the affiliate fee in its native + * asset CACAO. The stored `actualAffiliateFeeAmountCryptoBaseUnit` is the CACAO amount as + * reported by Midgard in THORChain precision (1e8) — NOT CACAO's native precision (1e10). + * + * This script, for each mislabeled Maya swap: + * 1. fetches the Midgard action by sellTxHash → block height + the affiliate CACAO fee out + * 2. fetches the ETH.USDC pool at that height → CACAO/USD spot price (balance_asset/balance_cacao, + * both normalized to 1e8, so the ratio is USDC-per-CACAO ≈ USD-per-CACAO) + * 3. plans an update: + * affiliateFeeAssetId = CACAO + * actualAffiliateFeeAmountCryptoBaseUnit = Midgard 1e8 amount shifted to CACAO native 1e10 + * affiliateAssetUsd = historical CACAO/USD + * + * Swaps with no affiliate fee out are only relabeled (no amount/price). + * + * DEPENDS ON a companion read-path change: `resolveActualFeeUsd` currently returns null when the + * fee asset is neither sell nor buy (precision unknown), so these values are correct-but-inert + * until it resolves the CACAO precision (10) and uses `affiliateAssetUsd`. See notes at bottom. + * + * Dry-run by default. Pass --apply to write. Reads DATABASE_URL from the environment. + * + * yarn backfill-maya-fee-asset # dry run, prints planned updates + * yarn backfill-maya-fee-asset --apply # execute updates + */ +import { PrismaClient } from '@prisma/client' +import BigNumber from 'bignumber.js' + +const MAYACHAIN_SWAPPER = 'MAYAChain' +const CACAO_ASSET_ID = 'cosmos:mayachain-mainnet-v1/slip44:931' +const CACAO_PRECISION = 10 +const MIDGARD_PRECISION = 8 // THORChain/Maya Midgard reports all amounts in 1e8 + +const MIDGARD_BASE = 'https://api.mayachain.shapeshift.com/midgard/v2' +const LCD_BASE = 'https://api.mayachain.shapeshift.com/lcd' +// USDC-denominated pool used as the CACAO/USD reference (USDC ≈ $1). +const USDC_POOL = 'ETH.USDC-0XA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48' + +const prisma = new PrismaClient() + +type MidgardCoin = { amount: string; asset: string } +type MidgardAction = { + height: string + out: { affiliate: boolean | null; coins: MidgardCoin[] }[] +} + +type MayaPool = { balance_asset: string; balance_cacao: string } + +const getJson = async (url: string): Promise => { + const res = await fetch(url) + if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`) + return res.json() as Promise +} + +const fetchAction = async (sellTxHash: string): Promise => { + const txid = sellTxHash.replace(/^0x/, '') + const data = await getJson<{ actions: MidgardAction[] }>(`${MIDGARD_BASE}/actions?txid=${txid}`) + return data.actions[0] ?? null +} + +// The affiliate fee output, asserted to be CACAO. Returns the 1e8 amount, or null if no affiliate out. +const getCacaoFeeAmount1e8 = (action: MidgardAction): string | null => { + const feeOut = action.out.find((o) => o.affiliate) + if (!feeOut) return null + const coin = feeOut.coins[0] + if (!coin || !coin.asset.toUpperCase().endsWith('CACAO')) { + throw new Error(`Affiliate fee out is not CACAO: ${coin?.asset}`) + } + return coin.amount +} + +// CACAO/USD from the USDC pool at a given height. Both balances are normalized to 1e8, so the +// ratio is directly USDC-per-CACAO. +const fetchCacaoUsd = async (height: string): Promise => { + // The LCD pool endpoint returns the pool object at the top level (not wrapped in `{ pool }`). + // balance_asset (USDC) and balance_cacao are both normalized to 1e8, so the ratio is USDC/CACAO. + const pool = await getJson(`${LCD_BASE}/mayachain/pool/${USDC_POOL}?height=${height}`) + const cacaoUsd = new BigNumber(pool.balance_asset).div(pool.balance_cacao) + if (!cacaoUsd.isFinite() || cacaoUsd.lte(0)) throw new Error(`Bad CACAO/USD from pool at height ${height}`) + return cacaoUsd.toString() +} + +const midgardToCacaoNative = (amount1e8: string): string => + new BigNumber(amount1e8).shiftedBy(CACAO_PRECISION - MIDGARD_PRECISION).toFixed(0) + +type PlannedUpdate = { + swapId: string + affiliateFeeAssetId: string + actualAffiliateFeeAmountCryptoBaseUnit?: string + affiliateAssetUsd?: string + note: string +} + +const planUpdate = async (swap: { + swapId: string + sellTxHash: string | null +}): Promise => { + const base = { swapId: swap.swapId, affiliateFeeAssetId: CACAO_ASSET_ID } + + if (!swap.sellTxHash) return { ...base, note: 'relabel only (no sellTxHash)' } + + const action = await fetchAction(swap.sellTxHash) + if (!action) return { ...base, note: 'relabel only (no Midgard action found)' } + + const feeAmount1e8 = getCacaoFeeAmount1e8(action) + if (!feeAmount1e8) return { ...base, note: 'relabel only (no affiliate fee out)' } + + const cacaoUsd = await fetchCacaoUsd(action.height) + const nativeAmount = midgardToCacaoNative(feeAmount1e8) + const feeUsd = new BigNumber(nativeAmount).shiftedBy(-CACAO_PRECISION).times(cacaoUsd) + + return { + ...base, + actualAffiliateFeeAmountCryptoBaseUnit: nativeAmount, + affiliateAssetUsd: cacaoUsd, + note: `height ${action.height} | ${feeAmount1e8} (1e8) → ${nativeAmount} (1e10) CACAO @ $${cacaoUsd} = $${feeUsd.toFixed(6)}`, + } +} + +const main = async (): Promise => { + const apply = process.argv.includes('--apply') + + const swaps = await prisma.swap.findMany({ + where: { + swapperName: MAYACHAIN_SWAPPER, + NOT: { affiliateFeeAssetId: CACAO_ASSET_ID }, + }, + select: { swapId: true, sellTxHash: true, partnerCode: true, isAffiliateVerified: true }, + orderBy: { createdAt: 'asc' }, + }) + + console.log(`${apply ? 'APPLYING' : 'DRY RUN'} — ${swaps.length} mislabeled MayaChain swaps\n`) + + let relabeled = 0 + let priced = 0 + const failures: { swapId: string; error: string }[] = [] + + for (const swap of swaps) { + try { + const plan = await planUpdate(swap) + const tag = swap.partnerCode ? `partner=${swap.partnerCode}` : 'shapeshift' + console.log(`• ${plan.swapId} [${tag}] ${plan.note}`) + + if (plan.affiliateAssetUsd) priced++ + else relabeled++ + + if (apply) { + await prisma.swap.update({ + where: { swapId: plan.swapId }, + data: { + affiliateFeeAssetId: plan.affiliateFeeAssetId, + ...(plan.actualAffiliateFeeAmountCryptoBaseUnit && { + actualAffiliateFeeAmountCryptoBaseUnit: plan.actualAffiliateFeeAmountCryptoBaseUnit, + }), + ...(plan.affiliateAssetUsd && { affiliateAssetUsd: plan.affiliateAssetUsd }), + }, + }) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`✗ ${swap.swapId}: ${message}`) + failures.push({ swapId: swap.swapId, error: message }) + } + } + + console.log(`\n${apply ? 'Applied' : 'Planned'}: ${priced} priced + ${relabeled} relabel-only; ${failures.length} failed`) + if (!apply) console.log('Re-run with --apply to write these changes.') +} + +main() + .catch((error) => { + console.error(error) + process.exit(1) + }) + .finally(() => prisma.$disconnect()) diff --git a/tsconfig.json b/tsconfig.json index 08b9853..f56dea2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,6 @@ "emitDecoratorMetadata": true, "strictPropertyInitialization": false }, - "include": ["apps/**/*", "packages/**/*", "eslint.config.ts", "prisma.config.ts", "scripts/referral-rewards.ts"], + "include": ["apps/**/*", "packages/**/*", "eslint.config.ts", "prisma.config.ts", "scripts/referral-rewards.ts", "scripts/backfill-maya-fee-asset.ts"], "exclude": ["dist", "node_modules"] } From daf31e45b9a74e3a71f50c631195d9ad029955c4 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:27:20 -0600 Subject: [PATCH 3/5] feat(swap-service): value RUNE/CACAO affiliate fees by native precision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveActualFeeUsd previously returned null when the affiliate fee asset was neither the sell nor buy asset, so the actual on-chain fee for Thorchain (RUNE) and Maya (CACAO) was never valued — affiliateAssetUsd was a fetched-but-dead column. Add explicit cases for thorchainAssetId (precision 8) and mayachainAssetId (precision 10), pricing the stored amount with affiliateAssetUsd. The stored amount must be in native base units for those precisions. Midgard reports in 1e8, so the shared verifier now scales the affiliate fee to the fee asset's native precision (RUNE 8 = no-op, CACAO 10 = x100). Historical Maya rows are corrected by scripts/backfill-maya-fee-asset.ts. Co-Authored-By: Claude Opus 4.8 --- apps/swap-service/src/swaps/utils.ts | 39 ++++++++++++++----- .../src/verification/__tests__/maya.test.ts | 3 +- .../verification/swap-verification.service.ts | 17 ++++++-- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/apps/swap-service/src/swaps/utils.ts b/apps/swap-service/src/swaps/utils.ts index 65acc69..241a1d7 100644 --- a/apps/swap-service/src/swaps/utils.ts +++ b/apps/swap-service/src/swaps/utils.ts @@ -3,6 +3,7 @@ import type { Swap as PrismaSwap } from '@prisma/client' import type { CreateSwapDto } from '@shapeshift/shared-types' import { baseUnitToPrecision } from '@shapeshift/shared-utils' +import { mayachainAssetId, thorchainAssetId } from '@shapeshiftoss/caip' import { bnOrZero } from '@shapeshiftoss/chain-adapters' import type { Swap as SwapperSwap, SwapperName, SwapperSpecificMetadata } from '@shapeshiftoss/swapper' import type { Asset } from '@shapeshiftoss/types' @@ -15,6 +16,11 @@ const logger = new Logger('SwapsService') const BPS_DENOMINATOR = 10000 +// Native precisions of the THORChain/Maya native fee assets — the precision the affiliate fee +// amount is stored in for these chains. +const RUNE_PRECISION = 8 +const CACAO_PRECISION = 10 + // Historical rows may persist `{}` for affiliateVerificationDetails; coerce anything // that doesn't satisfy the tightened shape (requires `hasAffiliate`) to null. const toAffiliateVerificationDetails = ( @@ -119,16 +125,29 @@ const resolveActualFeeUsd = (swap: Swap): number | null => { let priceUsd: string | null let precision: number | null - if (swap.affiliateFeeAssetId === swap.sellAsset.assetId) { - priceUsd = swap.sellAssetUsd - precision = swap.sellAsset.precision - } else if (swap.affiliateFeeAssetId === swap.buyAsset.assetId) { - priceUsd = swap.buyAssetUsd - precision = swap.buyAsset.precision - } else { - priceUsd = swap.affiliateAssetUsd - // Fee asset is neither sell nor buy — precision unknown - precision = null + switch (swap.affiliateFeeAssetId) { + case swap.sellAsset.assetId: + priceUsd = swap.sellAssetUsd + precision = swap.sellAsset.precision + break + case swap.buyAsset.assetId: + priceUsd = swap.buyAssetUsd + precision = swap.buyAsset.precision + break + case thorchainAssetId: + // Thorchain collects the affiliate fee in RUNE. + priceUsd = swap.affiliateAssetUsd + precision = RUNE_PRECISION + break + case mayachainAssetId: + // Mayachain collects the affiliate fee in CACAO. + priceUsd = swap.affiliateAssetUsd + precision = CACAO_PRECISION + break + default: + priceUsd = swap.affiliateAssetUsd + // Fee asset is neither sell nor buy nor a known native fee asset — precision unknown + precision = null } if (!priceUsd || precision === null) return null diff --git a/apps/swap-service/src/verification/__tests__/maya.test.ts b/apps/swap-service/src/verification/__tests__/maya.test.ts index f005284..71b35e5 100644 --- a/apps/swap-service/src/verification/__tests__/maya.test.ts +++ b/apps/swap-service/src/verification/__tests__/maya.test.ts @@ -33,7 +33,8 @@ describe('verifyMaya', () => { affiliateAddress: 'ssmaya', verifiedSellAmountCryptoBaseUnit: '4000000000000000', actualBuyAmountCryptoBaseUnit: '7340228', - actualAffiliateFeeAmountCryptoBaseUnit: '4237779000', + // CACAO fee from Midgard (1e8) scaled to native precision 10: 4237779000 × 100 + actualAffiliateFeeAmountCryptoBaseUnit: '423777900000', }) }) diff --git a/apps/swap-service/src/verification/swap-verification.service.ts b/apps/swap-service/src/verification/swap-verification.service.ts index cca6057..f4591a7 100644 --- a/apps/swap-service/src/verification/swap-verification.service.ts +++ b/apps/swap-service/src/verification/swap-verification.service.ts @@ -329,16 +329,22 @@ export class SwapVerificationService { } private verifyThorchain(swap: Swap): Promise { - return this.verifyMidgardSwap(swap, { midgardUrl: env.VITE_THORCHAIN_MIDGARD_URL, affiliate: 'ss' }) + // Fee collected in RUNE (precision 8); Midgard reports in 1e8 so this is a no-op conversion. + return this.verifyMidgardSwap(swap, { midgardUrl: env.VITE_THORCHAIN_MIDGARD_URL, affiliate: 'ss', feeAssetPrecision: 8 }) } private verifyMaya(swap: Swap): Promise { - return this.verifyMidgardSwap(swap, { midgardUrl: env.VITE_MAYACHAIN_MIDGARD_URL, affiliate: 'ssmaya' }) + // Fee collected in CACAO (precision 10); Midgard reports in 1e8, so scale up to native base units. + return this.verifyMidgardSwap(swap, { + midgardUrl: env.VITE_MAYACHAIN_MIDGARD_URL, + affiliate: 'ssmaya', + feeAssetPrecision: 10, + }) } private async verifyMidgardSwap( swap: Swap, - config: { midgardUrl: string; affiliate: string }, + config: { midgardUrl: string; affiliate: string; feeAssetPrecision: number }, ): Promise { const txHash = swap.sellTxHash?.replace(/^0x/, '') if (!txHash) return noAffiliateResult('FAILED', 'Missing sell txHash') @@ -382,7 +388,10 @@ export class SwapVerificationService { swap.sellAsset.precision, ), actualBuyAmountCryptoBaseUnit: thorchainToNativePrecision(buyOut.coins[0].amount, swap.buyAsset.precision), - actualAffiliateFeeAmountCryptoBaseUnit: hasAffiliate ? feeOut?.coins[0].amount : undefined, + actualAffiliateFeeAmountCryptoBaseUnit: + hasAffiliate && feeOut + ? thorchainToNativePrecision(feeOut.coins[0].amount, config.feeAssetPrecision) + : undefined, } } From 972069e37907d46cf16367bd4e438fea78283b23 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:01:26 -0600 Subject: [PATCH 4/5] chore(swap-service): remove one-off Maya backfill, tidy verifier The Maya fee-asset backfill has been applied; the one-off script (and its yarn entry / tsconfig include) is no longer needed. Also reformats verifyThorchain config formatting in the Midgard verifier. Co-Authored-By: Claude Opus 4.8 --- .../verification/swap-verification.service.ts | 8 +- package.json | 3 +- scripts/backfill-maya-fee-asset.ts | 178 ------------------ tsconfig.json | 2 +- 4 files changed, 7 insertions(+), 184 deletions(-) delete mode 100644 scripts/backfill-maya-fee-asset.ts diff --git a/apps/swap-service/src/verification/swap-verification.service.ts b/apps/swap-service/src/verification/swap-verification.service.ts index f4591a7..92fe1a6 100644 --- a/apps/swap-service/src/verification/swap-verification.service.ts +++ b/apps/swap-service/src/verification/swap-verification.service.ts @@ -329,12 +329,14 @@ export class SwapVerificationService { } private verifyThorchain(swap: Swap): Promise { - // Fee collected in RUNE (precision 8); Midgard reports in 1e8 so this is a no-op conversion. - return this.verifyMidgardSwap(swap, { midgardUrl: env.VITE_THORCHAIN_MIDGARD_URL, affiliate: 'ss', feeAssetPrecision: 8 }) + return this.verifyMidgardSwap(swap, { + midgardUrl: env.VITE_THORCHAIN_MIDGARD_URL, + affiliate: 'ss', + feeAssetPrecision: 8, + }) } private verifyMaya(swap: Swap): Promise { - // Fee collected in CACAO (precision 10); Midgard reports in 1e8, so scale up to native base units. return this.verifyMidgardSwap(swap, { midgardUrl: env.VITE_MAYACHAIN_MIDGARD_URL, affiliate: 'ssmaya', diff --git a/package.json b/package.json index 6fd72dd..f73a20e 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,7 @@ "db:migrate:status": "prisma migrate status", "db:migrate:create": "prisma migrate dev --create-only --name", "db:studio": "prisma studio", - "referral-rewards": "ts-node scripts/referral-rewards.ts", - "backfill-maya-fee-asset": "ts-node --transpile-only scripts/backfill-maya-fee-asset.ts" + "referral-rewards": "ts-node scripts/referral-rewards.ts" }, "dependencies": { "@bitcoinerlab/secp256k1": "^1.1.1", diff --git a/scripts/backfill-maya-fee-asset.ts b/scripts/backfill-maya-fee-asset.ts deleted file mode 100644 index 634c325..0000000 --- a/scripts/backfill-maya-fee-asset.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * One-off backfill: correct the affiliate fee asset for historical MayaChain swaps. - * - * Bug (fixed for new swaps in fix/mayachain-affiliate-fee-asset): Maya swaps were stored with - * `affiliateFeeAssetId` = the sell asset, but Maya collects the affiliate fee in its native - * asset CACAO. The stored `actualAffiliateFeeAmountCryptoBaseUnit` is the CACAO amount as - * reported by Midgard in THORChain precision (1e8) — NOT CACAO's native precision (1e10). - * - * This script, for each mislabeled Maya swap: - * 1. fetches the Midgard action by sellTxHash → block height + the affiliate CACAO fee out - * 2. fetches the ETH.USDC pool at that height → CACAO/USD spot price (balance_asset/balance_cacao, - * both normalized to 1e8, so the ratio is USDC-per-CACAO ≈ USD-per-CACAO) - * 3. plans an update: - * affiliateFeeAssetId = CACAO - * actualAffiliateFeeAmountCryptoBaseUnit = Midgard 1e8 amount shifted to CACAO native 1e10 - * affiliateAssetUsd = historical CACAO/USD - * - * Swaps with no affiliate fee out are only relabeled (no amount/price). - * - * DEPENDS ON a companion read-path change: `resolveActualFeeUsd` currently returns null when the - * fee asset is neither sell nor buy (precision unknown), so these values are correct-but-inert - * until it resolves the CACAO precision (10) and uses `affiliateAssetUsd`. See notes at bottom. - * - * Dry-run by default. Pass --apply to write. Reads DATABASE_URL from the environment. - * - * yarn backfill-maya-fee-asset # dry run, prints planned updates - * yarn backfill-maya-fee-asset --apply # execute updates - */ -import { PrismaClient } from '@prisma/client' -import BigNumber from 'bignumber.js' - -const MAYACHAIN_SWAPPER = 'MAYAChain' -const CACAO_ASSET_ID = 'cosmos:mayachain-mainnet-v1/slip44:931' -const CACAO_PRECISION = 10 -const MIDGARD_PRECISION = 8 // THORChain/Maya Midgard reports all amounts in 1e8 - -const MIDGARD_BASE = 'https://api.mayachain.shapeshift.com/midgard/v2' -const LCD_BASE = 'https://api.mayachain.shapeshift.com/lcd' -// USDC-denominated pool used as the CACAO/USD reference (USDC ≈ $1). -const USDC_POOL = 'ETH.USDC-0XA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48' - -const prisma = new PrismaClient() - -type MidgardCoin = { amount: string; asset: string } -type MidgardAction = { - height: string - out: { affiliate: boolean | null; coins: MidgardCoin[] }[] -} - -type MayaPool = { balance_asset: string; balance_cacao: string } - -const getJson = async (url: string): Promise => { - const res = await fetch(url) - if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`) - return res.json() as Promise -} - -const fetchAction = async (sellTxHash: string): Promise => { - const txid = sellTxHash.replace(/^0x/, '') - const data = await getJson<{ actions: MidgardAction[] }>(`${MIDGARD_BASE}/actions?txid=${txid}`) - return data.actions[0] ?? null -} - -// The affiliate fee output, asserted to be CACAO. Returns the 1e8 amount, or null if no affiliate out. -const getCacaoFeeAmount1e8 = (action: MidgardAction): string | null => { - const feeOut = action.out.find((o) => o.affiliate) - if (!feeOut) return null - const coin = feeOut.coins[0] - if (!coin || !coin.asset.toUpperCase().endsWith('CACAO')) { - throw new Error(`Affiliate fee out is not CACAO: ${coin?.asset}`) - } - return coin.amount -} - -// CACAO/USD from the USDC pool at a given height. Both balances are normalized to 1e8, so the -// ratio is directly USDC-per-CACAO. -const fetchCacaoUsd = async (height: string): Promise => { - // The LCD pool endpoint returns the pool object at the top level (not wrapped in `{ pool }`). - // balance_asset (USDC) and balance_cacao are both normalized to 1e8, so the ratio is USDC/CACAO. - const pool = await getJson(`${LCD_BASE}/mayachain/pool/${USDC_POOL}?height=${height}`) - const cacaoUsd = new BigNumber(pool.balance_asset).div(pool.balance_cacao) - if (!cacaoUsd.isFinite() || cacaoUsd.lte(0)) throw new Error(`Bad CACAO/USD from pool at height ${height}`) - return cacaoUsd.toString() -} - -const midgardToCacaoNative = (amount1e8: string): string => - new BigNumber(amount1e8).shiftedBy(CACAO_PRECISION - MIDGARD_PRECISION).toFixed(0) - -type PlannedUpdate = { - swapId: string - affiliateFeeAssetId: string - actualAffiliateFeeAmountCryptoBaseUnit?: string - affiliateAssetUsd?: string - note: string -} - -const planUpdate = async (swap: { - swapId: string - sellTxHash: string | null -}): Promise => { - const base = { swapId: swap.swapId, affiliateFeeAssetId: CACAO_ASSET_ID } - - if (!swap.sellTxHash) return { ...base, note: 'relabel only (no sellTxHash)' } - - const action = await fetchAction(swap.sellTxHash) - if (!action) return { ...base, note: 'relabel only (no Midgard action found)' } - - const feeAmount1e8 = getCacaoFeeAmount1e8(action) - if (!feeAmount1e8) return { ...base, note: 'relabel only (no affiliate fee out)' } - - const cacaoUsd = await fetchCacaoUsd(action.height) - const nativeAmount = midgardToCacaoNative(feeAmount1e8) - const feeUsd = new BigNumber(nativeAmount).shiftedBy(-CACAO_PRECISION).times(cacaoUsd) - - return { - ...base, - actualAffiliateFeeAmountCryptoBaseUnit: nativeAmount, - affiliateAssetUsd: cacaoUsd, - note: `height ${action.height} | ${feeAmount1e8} (1e8) → ${nativeAmount} (1e10) CACAO @ $${cacaoUsd} = $${feeUsd.toFixed(6)}`, - } -} - -const main = async (): Promise => { - const apply = process.argv.includes('--apply') - - const swaps = await prisma.swap.findMany({ - where: { - swapperName: MAYACHAIN_SWAPPER, - NOT: { affiliateFeeAssetId: CACAO_ASSET_ID }, - }, - select: { swapId: true, sellTxHash: true, partnerCode: true, isAffiliateVerified: true }, - orderBy: { createdAt: 'asc' }, - }) - - console.log(`${apply ? 'APPLYING' : 'DRY RUN'} — ${swaps.length} mislabeled MayaChain swaps\n`) - - let relabeled = 0 - let priced = 0 - const failures: { swapId: string; error: string }[] = [] - - for (const swap of swaps) { - try { - const plan = await planUpdate(swap) - const tag = swap.partnerCode ? `partner=${swap.partnerCode}` : 'shapeshift' - console.log(`• ${plan.swapId} [${tag}] ${plan.note}`) - - if (plan.affiliateAssetUsd) priced++ - else relabeled++ - - if (apply) { - await prisma.swap.update({ - where: { swapId: plan.swapId }, - data: { - affiliateFeeAssetId: plan.affiliateFeeAssetId, - ...(plan.actualAffiliateFeeAmountCryptoBaseUnit && { - actualAffiliateFeeAmountCryptoBaseUnit: plan.actualAffiliateFeeAmountCryptoBaseUnit, - }), - ...(plan.affiliateAssetUsd && { affiliateAssetUsd: plan.affiliateAssetUsd }), - }, - }) - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - console.error(`✗ ${swap.swapId}: ${message}`) - failures.push({ swapId: swap.swapId, error: message }) - } - } - - console.log(`\n${apply ? 'Applied' : 'Planned'}: ${priced} priced + ${relabeled} relabel-only; ${failures.length} failed`) - if (!apply) console.log('Re-run with --apply to write these changes.') -} - -main() - .catch((error) => { - console.error(error) - process.exit(1) - }) - .finally(() => prisma.$disconnect()) diff --git a/tsconfig.json b/tsconfig.json index f56dea2..08b9853 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,6 @@ "emitDecoratorMetadata": true, "strictPropertyInitialization": false }, - "include": ["apps/**/*", "packages/**/*", "eslint.config.ts", "prisma.config.ts", "scripts/referral-rewards.ts", "scripts/backfill-maya-fee-asset.ts"], + "include": ["apps/**/*", "packages/**/*", "eslint.config.ts", "prisma.config.ts", "scripts/referral-rewards.ts"], "exclude": ["dist", "node_modules"] } From d745a8079b2afe89afb016c20bbc78de01a76c72 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:42:54 -0600 Subject: [PATCH 5/5] fix(swap-service): guard empty affiliate feeOut coins array Coins array is non-empty per Midgard types, but guard against an empty array defensively so a malformed affiliate out leaves the fee amount undefined instead of throwing and dropping the swap to PENDING. Co-Authored-By: Claude Opus 4.8 --- apps/swap-service/src/verification/swap-verification.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/swap-service/src/verification/swap-verification.service.ts b/apps/swap-service/src/verification/swap-verification.service.ts index 92fe1a6..a9c5bb8 100644 --- a/apps/swap-service/src/verification/swap-verification.service.ts +++ b/apps/swap-service/src/verification/swap-verification.service.ts @@ -391,7 +391,7 @@ export class SwapVerificationService { ), actualBuyAmountCryptoBaseUnit: thorchainToNativePrecision(buyOut.coins[0].amount, swap.buyAsset.precision), actualAffiliateFeeAmountCryptoBaseUnit: - hasAffiliate && feeOut + hasAffiliate && feeOut?.coins[0]?.amount ? thorchainToNativePrecision(feeOut.coins[0].amount, config.feeAssetPrecision) : undefined, }