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
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
{
"actions": [
{
"date": "1778781664239595999",
"height": "16562704",
"in": [
{
"address": "0xa44c286ba83bb771cd0107b2c1df678435bd1535",
"coins": [
{
"amount": "400000",
"asset": "ETH.ETH"
}
],
"txID": "E4DAC04D7194ABA8F3A9FA154CD7BE3055A03D35797F1916D8721F6273FA16EB"
}
],
"metadata": {
"swap": {
"affiliateAddress": "ssmaya",
"affiliateFee": "60",
"inPriceUSD": "2292.5229642298773",
"isStreamingSwap": true,
"liquidityFee": "126740373",
"memo": "=:ETH.USDC:0xA44C286BA83Bb771cd0107B2c1Df678435Bd1535:724374326:ssmaya:60",
"networkFees": [
{
"amount": "170650000",
"asset": "ETH.USDC-0XA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48"
}
],
"outPriceUSD": "1.0158642784979675",
"streamingSwapMeta": {
"count": "1",
"depositedCoin": {
"amount": "0",
"asset": ""
},
"inCoin": {
"amount": "0",
"asset": ""
},
"interval": "0",
"lastHeight": "0",
"outCoin": {
"amount": "0",
"asset": ""
},
"outEstimation": "4237779000",
"quantity": "1"
},
"swapSlip": "2",
"swapTarget": "724374326"
}
},
"out": [
{
"address": "0xa44c286ba83bb771cd0107b2c1df678435bd1535",
"coins": [
{
"amount": "734022800",
"asset": "ETH.USDC-0XA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48"
}
],
"height": "16562711",
"txID": "389BD007E29E081811C735F773D360A05DFC3E0C8608781A85602A7C7AE2AD4B"
},
{
"address": "maya122h9hlrugzdny9ct95z6g7afvpzu34s73tgnyv",
"affiliate": true,
"coins": [
{
"amount": "4237779000",
"asset": "MAYA.CACAO"
}
],
"height": "16562705",
"txID": ""
}
],
"pools": [
"ETH.ETH",
"ETH.USDC-0XA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48"
],
"status": "success",
"type": "swap"
}
],
"count": "1",
"meta": {
"nextPageToken": "165627049000000001",
"prevPageToken": "165627049000000001"
}
}
82 changes: 82 additions & 0 deletions apps/swap-service/src/verification/__tests__/fixtures/maya/swap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { SwapperName } from '@shapeshiftoss/swapper'

import type { Swap } from '../../../../swaps/types'

export default {
swapId: '562f173c-1515-4a4e-bf6b-cd1b35a9db1f',
sellAsset: {
icon: 'https://rawcdn.githack.com/trustwallet/assets/32e51d582a890b3dd3135fe3ee7c20c2fd699a6d/blockchains/ethereum/info/logo.png',
name: 'Ethereum',
color: '#5C6BC0',
symbol: 'ETH',
assetId: 'eip155:1/slip44:60',
chainId: 'eip155:1',
explorer: 'https://etherscan.io',
precision: 18,
networkIcon:
'https://rawcdn.githack.com/trustwallet/assets/32e51d582a890b3dd3135fe3ee7c20c2fd699a6d/blockchains/ethereum/info/logo.png',
networkName: 'Ethereum',
networkColor: '#5C6BC0',
explorerTxLink: 'https://etherscan.io/tx/',
relatedAssetKey: 'eip155:1/slip44:60',
explorerAddressLink: 'https://etherscan.io/address/',
},
buyAsset: {
icon: 'https://assets.coingecko.com/coins/images/6319/large/USDC.png?1769615602',
name: 'USDC',
color: '#2373CB',
symbol: 'USDC',
assetId: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
chainId: 'eip155:1',
explorer: 'https://etherscan.io',
precision: 6,
networkIcon:
'https://rawcdn.githack.com/trustwallet/assets/32e51d582a890b3dd3135fe3ee7c20c2fd699a6d/blockchains/ethereum/info/logo.png',
networkName: 'Ethereum',
networkColor: '#5C6BC0',
explorerTxLink: 'https://etherscan.io/tx/',
relatedAssetKey: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
explorerAddressLink: 'https://etherscan.io/address/',
},
sellAmountCryptoBaseUnit: '4000000000000000',
expectedBuyAmountCryptoBaseUnit: '7280143.98',
actualBuyAmountCryptoBaseUnit: '7340228',
status: 'SUCCESS',
source: 'MAYAChain',
swapperName: SwapperName.Mayachain,
sellAccountId: '3c70e97c6f86a5b5cfdf82dfd3380ac4ed3d8b89dabf86f631bdf739372926be',
buyAccountId: null,
receiveAddress: '0xA44C286BA83Bb771cd0107B2c1Df678435Bd1535',
sellTxHash: '0xe4dac04d7194aba8f3a9fa154cd7be3055a03d35797f1916d8721f6273fa16eb',
buyTxHash: '0x389BD007E29E081811C735F773D360A05DFC3E0C8608781A85602A7C7AE2AD4B',
txLink: null,
statusMessage: '',
isStreaming: false,
createdAt: new Date('2026-05-14T18:00:35.263Z'),
updatedAt: new Date('2026-05-18T16:27:43.217Z'),
metadata: {
quoteId: '562f173c-1515-4a4e-bf6b-cd1b35a9db1f',
stepIndex: 0,
acrossTransactionMetadata: undefined,
chainflipSwapId: undefined,
debridgeTransactionMetadata: undefined,
relayerExplorerTxLink: undefined,
relayerTxHash: undefined,
relayTransactionMetadata: undefined,
streamingSwapMetadata: undefined,
},
userId: 'api',
referralCode: null,
sellAssetUsd: '2309.11',
buyAssetUsd: '0.99977',
affiliateAssetUsd: '2309.11',
isAffiliateVerified: null,
affiliateVerificationDetails: null,
affiliateAddress: '0xA44C286BA83Bb771cd0107B2c1Df678435Bd1535',
affiliateBps: 60,
origin: 'api',
affiliateFeeAssetId: 'eip155:1/slip44:60',
actualAffiliateFeeAmountCryptoBaseUnit: null,
shapeshiftBps: 10,
verificationStatus: 'PENDING',
} satisfies Swap
200 changes: 200 additions & 0 deletions apps/swap-service/src/verification/__tests__/maya.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import type { HttpService } from '@nestjs/axios'
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'

const swap = mayaSwap 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', () => {
let service: SwapVerificationService

beforeEach(() => {
jest.restoreAllMocks()
})

it('verifies a successful swap with shapeshift affiliate', async () => {
service = new SwapVerificationService(makeHttpMock(mayaResponse))

const result = await service.verifySwap(swap)

expect(result).toMatchObject({
verificationStatus: 'SUCCESS',
hasAffiliate: true,
affiliateBps: 60,
affiliateAddress: 'ssmaya',
verifiedSellAmountCryptoBaseUnit: '4000000000000000',
actualBuyAmountCryptoBaseUnit: '7340228',
actualAffiliateFeeAmountCryptoBaseUnit: '4237779000',
})
})

it('strips 0x prefix from sellTxHash before calling Midgard', async () => {
const get = jest.fn<unknown, [string]>().mockReturnValue(of({ data: mayaResponse }))
service = new SwapVerificationService({ get } as unknown as HttpService)

await service.verifySwap(swap)

const url = get.mock.calls[0][0]
expect(url).toMatch(/\/actions\?txid=[0-9a-f]+$/i)
expect(url).not.toMatch(/=0x/i)
})

it('does not attribute affiliate fields when the action affiliate is not ssmaya', async () => {
const response = structuredClone(mayaResponse)
response.actions[0].metadata.swap.affiliateAddress = 'other'

service = new SwapVerificationService(makeHttpMock(response))

const result = await service.verifySwap(swap)

expect(result.verificationStatus).toBe('SUCCESS')
expect(result.hasAffiliate).toBe(false)
expect(result.affiliateAddress).toBeUndefined()
expect(result.affiliateBps).toBeUndefined()
expect(result.actualAffiliateFeeAmountCryptoBaseUnit).toBeUndefined()
})

it('returns hasAffiliate=false 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))

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()
})

it('returns FAILED when sellTxHash is missing', async () => {
service = new SwapVerificationService(makeHttpMock(mayaResponse))

const result = await service.verifySwap({ ...swap, sellTxHash: null } as Swap)

expect(result).toMatchObject({
verificationStatus: 'FAILED',
hasAffiliate: false,
noAffiliateReason: 'Missing sell txHash',
})
})

it('returns PENDING when Midgard returns no actions', async () => {
service = new SwapVerificationService(makeHttpMock({ actions: [] }))

const result = await service.verifySwap(swap)

expect(result.verificationStatus).toBe('PENDING')
expect(result.noAffiliateReason).toBe('No action found in Midgard')
})

it('returns PENDING when the action is still pending', async () => {
const response = structuredClone(mayaResponse)
response.actions[0].status = 'pending'

service = new SwapVerificationService(makeHttpMock(response))

const result = await service.verifySwap(swap)

expect(result.verificationStatus).toBe('PENDING')
expect(result.noAffiliateReason).toBe('Swap action still pending')
})

it('returns FAILED when the action type is not swap', async () => {
const response = structuredClone(mayaResponse)
response.actions[0].type = 'addLiquidity'

service = new SwapVerificationService(makeHttpMock(response))

const result = await service.verifySwap(swap)

expect(result.verificationStatus).toBe('FAILED')
expect(result.noAffiliateReason).toBe('Invalid swap action type')
})

it('returns FAILED when swap metadata is missing', async () => {
const response = structuredClone(mayaResponse) as {
actions: Array<{ metadata: { swap?: unknown } }>
}
delete response.actions[0].metadata.swap

service = new SwapVerificationService(makeHttpMock(response))

const result = await service.verifySwap(swap)

expect(result.verificationStatus).toBe('FAILED')
expect(result.noAffiliateReason).toBe('No swap metadata found')
})

it('selects the buy out by memo destination rather than array position', async () => {
const response = structuredClone(mayaResponse)
response.actions[0].out.reverse()

service = new SwapVerificationService(makeHttpMock(response))

const result = await service.verifySwap(swap)

expect(result.actualBuyAmountCryptoBaseUnit).toBe('7340228')
})

it('returns FAILED when no out matches the memo destination', async () => {
const response = structuredClone(mayaResponse)
response.actions[0].out = response.actions[0].out.map((out) =>
'affiliate' in out && out.affiliate ? out : { ...out, address: '0xdeadbeef' },
)

service = new SwapVerificationService(makeHttpMock(response))

const result = await service.verifySwap(swap)

expect(result.verificationStatus).toBe('FAILED')
expect(result.noAffiliateReason).toBe('No outbound matching memo destination')
})

it('returns FAILED when the action status is failed (refund)', async () => {
const response = structuredClone(mayaResponse)
response.actions[0].status = 'failed'

service = new SwapVerificationService(makeHttpMock(response))

const result = await service.verifySwap(swap)

expect(result.verificationStatus).toBe('FAILED')
expect(result.noAffiliateReason).toBe('Swap action failed')
})

it('returns FAILED when the memo has no destination address', async () => {
const response = structuredClone(mayaResponse)
response.actions[0].metadata.swap.memo = ''

service = new SwapVerificationService(makeHttpMock(response))

const result = await service.verifySwap(swap)

expect(result.verificationStatus).toBe('FAILED')
expect(result.noAffiliateReason).toBe('Could not parse destination address from memo')
})

it('returns PENDING when the HTTP call fails (transient — retry next tick)', async () => {
const httpMock = {
get: jest.fn().mockReturnValue(throwError(() => new Error('upstream 500'))),
} as unknown as HttpService

service = new SwapVerificationService(httpMock)

const result = await service.verifySwap(swap)

expect(result.verificationStatus).toBe('PENDING')
expect(result.noAffiliateReason).toBe('upstream 500')
})
})
2 changes: 1 addition & 1 deletion apps/swap-service/src/verification/__tests__/relay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ describe('verifyRelay', () => {
const result = await service.verifySwap(swap)

expect(result.verificationStatus).toBe('PENDING')
expect(result.noAffiliateReason).toBe('No request data found from Relay API')
expect(result.noAffiliateReason).toBe('No request data returned from Relay API')
})

it('returns PENDING when the HTTP call fails (transient — retry next tick)', async () => {
Expand Down
4 changes: 2 additions & 2 deletions apps/swap-service/src/verification/__tests__/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ jest.mock('../../env', () => ({
VITE_CHAINFLIP_API_KEY: 'x',
VITE_NEAR_INTENTS_API_KEY: 'x',
VITE_THORCHAIN_NODE_URL: 'https://thornode.test',
VITE_THORCHAIN_MIDGARD_URL: 'https://midgard.test',
VITE_MAYACHAIN_NODE_URL: 'https://mayanode.test',
VITE_THORCHAIN_MIDGARD_URL: 'https://thorchain-midgard.test',
VITE_MAYACHAIN_MIDGARD_URL: 'https://mayachain-midgard.test',
VITE_ACROSS_API_URL: 'https://across.test',
VITE_BEBOP_API_URL: 'https://bebop.test',
VITE_CHAINFLIP_API_URL: 'https://chainflip.test',
Expand Down
Loading
Loading