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 098957d..f23361b 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 @@ -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 } @@ -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 } } 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 81% rename from apps/swap-service/src/verification/__tests__/maya.test.ts rename to apps/swap-service/src/verification/__tests__/mayachain.test.ts index 71b35e5..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) @@ -33,13 +33,13 @@ 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', }) }) 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)) @@ -64,22 +64,22 @@ describe('verifyMaya', () => { expect(result.actualAffiliateFeeAmountCryptoBaseUnit).toBeUndefined() }) - it('returns hasAffiliate=false when affiliateAddress is ssmaya but no fee was paid out', async () => { - const response = structuredClone(mayaResponse) + it('attributes affiliate with no fee amount when affiliateAddress is ssmaya but no fee was paid out', async () => { + const response = structuredClone(mayachainResponse) response.actions[0].out = response.actions[0].out.filter((out) => !('affiliate' in out && out.affiliate)) service = new SwapVerificationService(makeHttpMock(response)) 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 () => { - 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/__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..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: @@ -333,21 +333,19 @@ export class SwapVerificationService { return this.verifyMidgardSwap(swap, { midgardUrl: env.VITE_THORCHAIN_MIDGARD_URL, affiliate: 'ss', - feeAssetPrecision: 8, }) } - private verifyMaya(swap: Swap): Promise { + private verifyMayachain(swap: Swap): Promise { 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, } }