From ca6ea7b1a0b12038489735cac6192d8fc6603511 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:05:01 -0600 Subject: [PATCH 1/3] fix(swap-verification): value Maya affiliate fee with raw CACAO amount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maya Midgard reports the native CACAO fee in its native 1e10 precision (pool/external assets are 1e8), so the shared Midgard verifier must store feeOut.coins[0].amount raw. It was running the amount through thorchainToNativePrecision (×100 for Maya), inflating on-chain fee USD 100× (e.g. $3.76 vs correct $0.038 for a ~70bps fee on a ~$5 swap). Removed the feeAssetPrecision config; Thorchain is unaffected (RUNE native = 8). Also attribute verified affiliates that collect no fee: hasAffiliate now keys off the affiliate-address match alone (dropping the && feeOut monkey patch), reporting affiliateBps with a '0' fee amount for 0-bps swaps. calculateFeeForSwap guards verifiedBps with === undefined so real 0-bps affiliate swaps aren't skipped. Co-Authored-By: Claude Opus 4.8 --- apps/swap-service/src/swaps/utils.ts | 2 +- .../src/verification/__tests__/maya.test.ts | 14 +++++++------- .../src/verification/__tests__/thorchain.test.ts | 10 +++++----- .../src/verification/swap-verification.service.ts | 11 +++-------- 4 files changed, 16 insertions(+), 21 deletions(-) diff --git a/apps/swap-service/src/swaps/utils.ts b/apps/swap-service/src/swaps/utils.ts index 098957d..57f9294 100644 --- a/apps/swap-service/src/swaps/utils.ts +++ b/apps/swap-service/src/swaps/utils.ts @@ -169,7 +169,7 @@ export const calculateFeeForSwap = ( impliedFeeUsd: number | null } | null => { const verifiedBps = swap.affiliateVerificationDetails?.affiliateBps - if (!verifiedBps) { + if (verifiedBps === undefined) { logger.warn(`Verified swap ${swap.swapId} missing affiliate bps in verification details, skipping`) 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 71b35e5..0f199f0 100644 --- a/apps/swap-service/src/verification/__tests__/maya.test.ts +++ b/apps/swap-service/src/verification/__tests__/maya.test.ts @@ -33,8 +33,8 @@ describe('verifyMaya', () => { affiliateAddress: 'ssmaya', verifiedSellAmountCryptoBaseUnit: '4000000000000000', actualBuyAmountCryptoBaseUnit: '7340228', - // CACAO fee from Midgard (1e8) scaled to native precision 10: 4237779000 × 100 - actualAffiliateFeeAmountCryptoBaseUnit: '423777900000', + // Midgard reports CACAO in native 1e10 precision, so the raw affiliate out amount is used as-is. + actualAffiliateFeeAmountCryptoBaseUnit: '4237779000', }) }) @@ -64,7 +64,7 @@ describe('verifyMaya', () => { expect(result.actualAffiliateFeeAmountCryptoBaseUnit).toBeUndefined() }) - it('returns hasAffiliate=false when affiliateAddress is ssmaya but no fee was paid out', async () => { + it('attributes affiliate with no fee amount when affiliateAddress is ssmaya but no fee was paid out', async () => { const response = structuredClone(mayaResponse) response.actions[0].out = response.actions[0].out.filter((out) => !('affiliate' in out && out.affiliate)) @@ -72,10 +72,10 @@ describe('verifyMaya', () => { const result = await service.verifySwap(swap) - expect(result.hasAffiliate).toBe(false) - expect(result.affiliateAddress).toBeUndefined() - expect(result.affiliateBps).toBeUndefined() - expect(result.actualAffiliateFeeAmountCryptoBaseUnit).toBeUndefined() + expect(result.hasAffiliate).toBe(true) + expect(result.affiliateAddress).toBe('ssmaya') + expect(result.affiliateBps).toBe(60) + expect(result.actualAffiliateFeeAmountCryptoBaseUnit).toBe('0') }) it('returns FAILED when sellTxHash is missing', async () => { diff --git a/apps/swap-service/src/verification/__tests__/thorchain.test.ts b/apps/swap-service/src/verification/__tests__/thorchain.test.ts index f40abda..91a91ac 100644 --- a/apps/swap-service/src/verification/__tests__/thorchain.test.ts +++ b/apps/swap-service/src/verification/__tests__/thorchain.test.ts @@ -63,7 +63,7 @@ describe('verifyThorchain', () => { expect(result.actualAffiliateFeeAmountCryptoBaseUnit).toBeUndefined() }) - it('returns hasAffiliate=false when affiliateAddress is ss but no fee was paid out', async () => { + it('attributes affiliate with no fee amount when affiliateAddress is ss but no fee was paid out', async () => { const response = structuredClone(thorchainResponse) response.actions[0].out = response.actions[0].out.filter((out) => !out.affiliate) @@ -71,10 +71,10 @@ describe('verifyThorchain', () => { const result = await service.verifySwap(swap) - expect(result.hasAffiliate).toBe(false) - expect(result.affiliateAddress).toBeUndefined() - expect(result.affiliateBps).toBeUndefined() - expect(result.actualAffiliateFeeAmountCryptoBaseUnit).toBeUndefined() + expect(result.hasAffiliate).toBe(true) + expect(result.affiliateAddress).toBe('ss') + expect(result.affiliateBps).toBe(60) + expect(result.actualAffiliateFeeAmountCryptoBaseUnit).toBe('0') }) it('returns FAILED when sellTxHash is missing', async () => { diff --git a/apps/swap-service/src/verification/swap-verification.service.ts b/apps/swap-service/src/verification/swap-verification.service.ts index 10e9262..77a36fb 100644 --- a/apps/swap-service/src/verification/swap-verification.service.ts +++ b/apps/swap-service/src/verification/swap-verification.service.ts @@ -333,7 +333,6 @@ export class SwapVerificationService { return this.verifyMidgardSwap(swap, { midgardUrl: env.VITE_THORCHAIN_MIDGARD_URL, affiliate: 'ss', - feeAssetPrecision: 8, }) } @@ -341,13 +340,12 @@ export class SwapVerificationService { return this.verifyMidgardSwap(swap, { midgardUrl: env.VITE_MAYACHAIN_MIDGARD_URL, affiliate: 'ssmaya', - feeAssetPrecision: 10, }) } private async verifyMidgardSwap( swap: Swap, - config: { midgardUrl: string; affiliate: string; feeAssetPrecision: number }, + config: { midgardUrl: string; affiliate: string }, ): Promise { const txHash = swap.sellTxHash?.replace(/^0x/, '') if (!txHash) return noAffiliateResult('FAILED', 'Missing sell txHash') @@ -379,7 +377,7 @@ export class SwapVerificationService { if (!buyOut) return noAffiliateResult('FAILED', 'No outbound matching memo destination') const feeOut = action.out.find((out) => out.affiliate) - const hasAffiliate = affiliateAddress === config.affiliate && !!feeOut + const hasAffiliate = affiliateAddress === config.affiliate return { verificationStatus: 'SUCCESS', @@ -391,10 +389,7 @@ export class SwapVerificationService { swap.sellAsset.precision, ), actualBuyAmountCryptoBaseUnit: thorchainToNativePrecision(buyOut.coins[0].amount, swap.buyAsset.precision), - actualAffiliateFeeAmountCryptoBaseUnit: - hasAffiliate && feeOut?.coins[0]?.amount - ? thorchainToNativePrecision(feeOut.coins[0].amount, config.feeAssetPrecision) - : undefined, + actualAffiliateFeeAmountCryptoBaseUnit: hasAffiliate ? (feeOut?.coins[0]?.amount ?? '0') : undefined, } } From 74ba50cd9fd790c49b450c6772fb4a1eee69b9b4 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:14:43 -0600 Subject: [PATCH 2/3] chore(swap-verification): rename Maya -> Mayachain for naming consistency Align the MAYAChain verifier naming with the fully-spelled Thorchain convention: verifyMaya -> verifyMayachain, test fixtures maya/ -> mayachain/ (mayaResponse/mayaSwap -> mayachainResponse/mayachainSwap), maya.test.ts -> mayachain.test.ts, and the RUNE/CACAO precision comment. No behavior change. Co-Authored-By: Claude Opus 4.8 --- apps/swap-service/src/swaps/utils.ts | 2 +- .../{maya => mayachain}/response.json | 0 .../fixtures/{maya => mayachain}/swap.ts | 0 .../{maya.test.ts => mayachain.test.ts} | 32 +++++++++---------- .../verification/swap-verification.service.ts | 4 +-- 5 files changed, 19 insertions(+), 19 deletions(-) rename apps/swap-service/src/verification/__tests__/fixtures/{maya => mayachain}/response.json (100%) rename apps/swap-service/src/verification/__tests__/fixtures/{maya => mayachain}/swap.ts (100%) rename apps/swap-service/src/verification/__tests__/{maya.test.ts => mayachain.test.ts} (87%) diff --git a/apps/swap-service/src/swaps/utils.ts b/apps/swap-service/src/swaps/utils.ts index 57f9294..430140a 100644 --- a/apps/swap-service/src/swaps/utils.ts +++ b/apps/swap-service/src/swaps/utils.ts @@ -16,7 +16,7 @@ const logger = new Logger('SwapsService') const BPS_DENOMINATOR = 10000 -// Native precisions of the THORChain/Maya native fee assets — the precision the affiliate fee +// Native precisions of the THORChain/MAYAChain native fee assets — the precision the affiliate fee // amount is stored in for these chains. const RUNE_PRECISION = 8 const CACAO_PRECISION = 10 diff --git a/apps/swap-service/src/verification/__tests__/fixtures/maya/response.json b/apps/swap-service/src/verification/__tests__/fixtures/mayachain/response.json similarity index 100% rename from apps/swap-service/src/verification/__tests__/fixtures/maya/response.json rename to apps/swap-service/src/verification/__tests__/fixtures/mayachain/response.json diff --git a/apps/swap-service/src/verification/__tests__/fixtures/maya/swap.ts b/apps/swap-service/src/verification/__tests__/fixtures/mayachain/swap.ts similarity index 100% rename from apps/swap-service/src/verification/__tests__/fixtures/maya/swap.ts rename to apps/swap-service/src/verification/__tests__/fixtures/mayachain/swap.ts diff --git a/apps/swap-service/src/verification/__tests__/maya.test.ts b/apps/swap-service/src/verification/__tests__/mayachain.test.ts similarity index 87% rename from apps/swap-service/src/verification/__tests__/maya.test.ts rename to apps/swap-service/src/verification/__tests__/mayachain.test.ts index 0f199f0..bf3d0e6 100644 --- a/apps/swap-service/src/verification/__tests__/maya.test.ts +++ b/apps/swap-service/src/verification/__tests__/mayachain.test.ts @@ -4,17 +4,17 @@ import { of, throwError } from 'rxjs' import type { Swap } from '../../swaps/types' import { SwapVerificationService } from '../swap-verification.service' -import mayaResponse from './fixtures/maya/response.json' -import mayaSwap from './fixtures/maya/swap' +import mayachainResponse from './fixtures/mayachain/response.json' +import mayachainSwap from './fixtures/mayachain/swap' -const swap = mayaSwap as unknown as Swap +const swap = mayachainSwap as unknown as Swap const makeHttpMock = (response: unknown): HttpService => { const get = jest.fn().mockReturnValue(of({ data: response })) return { get } as unknown as HttpService } -describe('verifyMaya', () => { +describe('verifyMayachain', () => { let service: SwapVerificationService beforeEach(() => { @@ -22,7 +22,7 @@ describe('verifyMaya', () => { }) it('verifies a successful swap with shapeshift affiliate', async () => { - service = new SwapVerificationService(makeHttpMock(mayaResponse)) + service = new SwapVerificationService(makeHttpMock(mayachainResponse)) const result = await service.verifySwap(swap) @@ -39,7 +39,7 @@ describe('verifyMaya', () => { }) it('strips 0x prefix from sellTxHash before calling Midgard', async () => { - const get = jest.fn().mockReturnValue(of({ data: mayaResponse })) + const get = jest.fn().mockReturnValue(of({ data: mayachainResponse })) service = new SwapVerificationService({ get } as unknown as HttpService) await service.verifySwap(swap) @@ -50,7 +50,7 @@ describe('verifyMaya', () => { }) it('does not attribute affiliate fields when the action affiliate is not ssmaya', async () => { - const response = structuredClone(mayaResponse) + const response = structuredClone(mayachainResponse) response.actions[0].metadata.swap.affiliateAddress = 'other' service = new SwapVerificationService(makeHttpMock(response)) @@ -65,7 +65,7 @@ describe('verifyMaya', () => { }) it('attributes affiliate with no fee amount when affiliateAddress is ssmaya but no fee was paid out', async () => { - const response = structuredClone(mayaResponse) + const response = structuredClone(mayachainResponse) response.actions[0].out = response.actions[0].out.filter((out) => !('affiliate' in out && out.affiliate)) service = new SwapVerificationService(makeHttpMock(response)) @@ -79,7 +79,7 @@ describe('verifyMaya', () => { }) it('returns FAILED when sellTxHash is missing', async () => { - service = new SwapVerificationService(makeHttpMock(mayaResponse)) + service = new SwapVerificationService(makeHttpMock(mayachainResponse)) const result = await service.verifySwap({ ...swap, sellTxHash: null } as Swap) @@ -100,7 +100,7 @@ describe('verifyMaya', () => { }) it('returns PENDING when the action is still pending', async () => { - const response = structuredClone(mayaResponse) + const response = structuredClone(mayachainResponse) response.actions[0].status = 'pending' service = new SwapVerificationService(makeHttpMock(response)) @@ -112,7 +112,7 @@ describe('verifyMaya', () => { }) it('returns FAILED when the action type is not swap', async () => { - const response = structuredClone(mayaResponse) + const response = structuredClone(mayachainResponse) response.actions[0].type = 'addLiquidity' service = new SwapVerificationService(makeHttpMock(response)) @@ -124,7 +124,7 @@ describe('verifyMaya', () => { }) it('returns FAILED when swap metadata is missing', async () => { - const response = structuredClone(mayaResponse) as { + const response = structuredClone(mayachainResponse) as { actions: Array<{ metadata: { swap?: unknown } }> } delete response.actions[0].metadata.swap @@ -138,7 +138,7 @@ describe('verifyMaya', () => { }) it('selects the buy out by memo destination rather than array position', async () => { - const response = structuredClone(mayaResponse) + const response = structuredClone(mayachainResponse) response.actions[0].out.reverse() service = new SwapVerificationService(makeHttpMock(response)) @@ -149,7 +149,7 @@ describe('verifyMaya', () => { }) it('returns FAILED when no out matches the memo destination', async () => { - const response = structuredClone(mayaResponse) + const response = structuredClone(mayachainResponse) response.actions[0].out = response.actions[0].out.map((out) => 'affiliate' in out && out.affiliate ? out : { ...out, address: '0xdeadbeef' }, ) @@ -163,7 +163,7 @@ describe('verifyMaya', () => { }) it('returns FAILED when the action status is failed (refund)', async () => { - const response = structuredClone(mayaResponse) + const response = structuredClone(mayachainResponse) response.actions[0].status = 'failed' service = new SwapVerificationService(makeHttpMock(response)) @@ -175,7 +175,7 @@ describe('verifyMaya', () => { }) it('returns FAILED when the memo has no destination address', async () => { - const response = structuredClone(mayaResponse) + const response = structuredClone(mayachainResponse) response.actions[0].metadata.swap.memo = '' service = new SwapVerificationService(makeHttpMock(response)) diff --git a/apps/swap-service/src/verification/swap-verification.service.ts b/apps/swap-service/src/verification/swap-verification.service.ts index 77a36fb..591194d 100644 --- a/apps/swap-service/src/verification/swap-verification.service.ts +++ b/apps/swap-service/src/verification/swap-verification.service.ts @@ -73,7 +73,7 @@ export class SwapVerificationService { case SwapperName.Thorchain: return await this.verifyThorchain(swap) case SwapperName.Mayachain: - return await this.verifyMaya(swap) + return await this.verifyMayachain(swap) case SwapperName.Chainflip: return await this.verifyChainflip(swap) case SwapperName.Zrx: @@ -336,7 +336,7 @@ export class SwapVerificationService { }) } - private verifyMaya(swap: Swap): Promise { + private verifyMayachain(swap: Swap): Promise { return this.verifyMidgardSwap(swap, { midgardUrl: env.VITE_MAYACHAIN_MIDGARD_URL, affiliate: 'ssmaya', From 52fd95420a543367d35ef5b1627b8cbb684409cc Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:45:02 -0600 Subject: [PATCH 3/3] fix(swaps): guard 0-bps volume reconstruction against divide-by-zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit calculateFeeForSwap now lets verifiedBps === 0 through so real 0-bps affiliate swaps aren't skipped. The volumeUsd fallback reconstructs volume from the fee (fee × 10000 ÷ bps), which is NaN/Infinity at 0 bps and poisons aggregated affiliate stats. When the sell price is missing at 0 bps volume is genuinely unknown — record 0 and warn instead. Adds unit tests for the volume branches. Co-Authored-By: Claude Opus 4.8 --- .../src/swaps/__tests__/utils.test.ts | 73 +++++++++++++++++++ apps/swap-service/src/swaps/utils.ts | 9 ++- 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 apps/swap-service/src/swaps/__tests__/utils.test.ts diff --git a/apps/swap-service/src/swaps/__tests__/utils.test.ts b/apps/swap-service/src/swaps/__tests__/utils.test.ts new file mode 100644 index 0000000..1fa17a6 --- /dev/null +++ b/apps/swap-service/src/swaps/__tests__/utils.test.ts @@ -0,0 +1,73 @@ +import { Logger } from '@nestjs/common' + +import { mayachainAssetId } from '@shapeshiftoss/caip' + +import type { Swap } from '../types' +import { calculateFeeForSwap } from '../utils' + +// Minimal swap shape exercising calculateFeeForSwap's fee/volume math. CACAO fee asset so a stored +// '0' fee amount resolves to actualFeeUsd = 0 (the real 0-bps case this branch introduced). +const makeSwap = (overrides: Partial = {}): Swap => + ({ + swapId: 'test-swap', + sellAsset: { assetId: 'eip155:1/slip44:60', precision: 18 }, + buyAsset: { assetId: 'eip155:1/erc20:0xusdc', precision: 6 }, + sellAssetUsd: '2000', + buyAssetUsd: '1', + affiliateAssetUsd: '0.1', + affiliateFeeAssetId: mayachainAssetId, + actualAffiliateFeeAmountCryptoBaseUnit: '0', + actualBuyAmountCryptoBaseUnit: '5000000', // 5 USDC + expectedBuyAmountCryptoBaseUnit: '5000000', + affiliateVerificationDetails: { + hasAffiliate: true, + affiliateBps: 0, + verifiedSellAmountCryptoBaseUnit: '1000000000000000', // 0.001 ETH + }, + ...overrides, + }) as unknown as Swap + +describe('calculateFeeForSwap volume reconstruction', () => { + afterEach(() => jest.restoreAllMocks()) + + it('uses the sell-side USD as volume for a 0-bps swap when the sell price is present', () => { + const result = calculateFeeForSwap(makeSwap()) + + expect(result).not.toBeNull() + expect(result?.feeUsd).toBe(0) + // 0.001 ETH * $2000 + expect(result?.volumeUsd).toBe(2) + }) + + it('records volume 0 (not NaN/Infinity) and warns for a 0-bps swap when the sell price is missing', () => { + const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined) + + const result = calculateFeeForSwap(makeSwap({ sellAssetUsd: null })) + + expect(result).not.toBeNull() + expect(result?.feeUsd).toBe(0) + expect(result?.volumeUsd).toBe(0) + expect(Number.isFinite(result?.volumeUsd)).toBe(true) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('volume unknown')) + }) + + it('still reconstructs volume from the fee at >0 bps when the sell price is missing', () => { + const result = calculateFeeForSwap( + makeSwap({ + sellAssetUsd: null, + // 1e11 CACAO base units / 1e10 * $0.1 = $1 actual fee + actualAffiliateFeeAmountCryptoBaseUnit: '100000000000', + affiliateVerificationDetails: { + hasAffiliate: true, + affiliateBps: 100, // 1% + verifiedSellAmountCryptoBaseUnit: '1000000000000000', + }, + }), + ) + + expect(result).not.toBeNull() + expect(result?.feeUsd).toBe(1) + // fee $1 / 1% = $100 volume + expect(result?.volumeUsd).toBe(100) + }) +}) diff --git a/apps/swap-service/src/swaps/utils.ts b/apps/swap-service/src/swaps/utils.ts index 430140a..f23361b 100644 --- a/apps/swap-service/src/swaps/utils.ts +++ b/apps/swap-service/src/swaps/utils.ts @@ -198,7 +198,14 @@ export const calculateFeeForSwap = ( return null } - const volumeUsd = sellAmountUsd ?? bnOrZero(actualFeeUsd).times(BPS_DENOMINATOR).div(verifiedBps).toNumber() + const volumeUsd = (() => { + if (sellAmountUsd !== null) return sellAmountUsd + if (verifiedBps === 0) { + logger.warn(`Swap ${swap.swapId} has 0 bps and no sell price; volume unknown`) + return 0 + } + return bnOrZero(actualFeeUsd).times(BPS_DENOMINATOR).div(verifiedBps).toNumber() + })() return { feeUsd, volumeUsd, verifiedBps, actualFeeUsd, impliedFeeUsd } }