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
73 changes: 73 additions & 0 deletions apps/swap-service/src/swaps/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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)
})
})
13 changes: 10 additions & 3 deletions apps/swap-service/src/swaps/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 }
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,25 @@ 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(() => {
jest.restoreAllMocks()
})

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)

Expand All @@ -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<unknown, [string]>().mockReturnValue(of({ data: mayaResponse }))
const get = jest.fn<unknown, [string]>().mockReturnValue(of({ data: mayachainResponse }))
service = new SwapVerificationService({ get } as unknown as HttpService)

await service.verifySwap(swap)
Expand All @@ -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))
Expand All @@ -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)

Expand All @@ -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))
Expand All @@ -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))
Expand All @@ -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
Expand All @@ -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))
Expand All @@ -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' },
)
Expand All @@ -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))
Expand All @@ -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))
Expand Down
10 changes: 5 additions & 5 deletions apps/swap-service/src/verification/__tests__/thorchain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,18 +63,18 @@ 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)

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('ss')
expect(result.affiliateBps).toBe(60)
expect(result.actualAffiliateFeeAmountCryptoBaseUnit).toBe('0')
})

it('returns FAILED when sellTxHash is missing', async () => {
Expand Down
15 changes: 5 additions & 10 deletions apps/swap-service/src/verification/swap-verification.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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<SwapVerificationResult> {
private verifyMayachain(swap: Swap): Promise<SwapVerificationResult> {
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<SwapVerificationResult> {
const txHash = swap.sellTxHash?.replace(/^0x/, '')
if (!txHash) return noAffiliateResult('FAILED', 'Missing sell txHash')
Expand Down Expand Up @@ -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',
Expand All @@ -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,
}
}

Expand Down
Loading