Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ node_modules
# Claude Code (local-only)
.claude/settings.local.json
.claude/worktrees/
docs/superpowers/
1 change: 1 addition & 0 deletions apps/swap-service/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export const env = Object.freeze({
VITE_ACROSS_API_URL: 'https://app.across.to/api',
VITE_BEBOP_API_URL: 'https://api.bebop.xyz',
VITE_CHAINFLIP_API_URL: 'https://chainflip-broker.io',
VITE_CHAINFLIP_EXPLORER_URL: 'https://explorer-service-processor.chainflip.io/graphql',
VITE_COWSWAP_BASE_URL: 'https://api.cow.fi',
VITE_DEBRIDGE_API_URL: '',
VITE_NEAR_FAST_API_URL: 'https://api.fastnear.com',
Expand Down
2 changes: 1 addition & 1 deletion apps/swap-service/src/swaps/swaps.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ export class SwapsService {
verificationStatus: verificationResult.verificationStatus,
isAffiliateVerified,
affiliateFeeAssetId: verificationResult.actualAffiliateFeeAssetId,
affiliateAssetUsd: verificationResult.actualAffiliateFeeUsd,
affiliateAssetUsd: verificationResult.actualAffiliateAssetUsd,
affiliateVerificationDetails: isAffiliateVerified ? affiliateVerificationDetails : Prisma.DbNull,
actualBuyAmountCryptoBaseUnit: verificationResult.actualBuyAmountCryptoBaseUnit,
actualAffiliateFeeAmountCryptoBaseUnit: verificationResult.actualAffiliateFeeAmountCryptoBaseUnit,
Expand Down
10 changes: 7 additions & 3 deletions apps/swap-service/src/swaps/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +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 { mayachainAssetId, thorchainAssetId, usdcAssetId } 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'
Expand All @@ -16,10 +16,9 @@ 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
const USDC_PRECISION = 6

// Historical rows may persist `{}` for affiliateVerificationDetails; coerce anything
// that doesn't satisfy the tightened shape (requires `hasAffiliate`) to null.
Expand Down Expand Up @@ -148,6 +147,11 @@ const resolveActualFeeUsd = (swap: Swap): number | null => {
priceUsd = swap.affiliateAssetUsd
precision = CACAO_PRECISION
break
case usdcAssetId:
// Chainflip collects the affiliate fee in USDC.
priceUsd = swap.affiliateAssetUsd
precision = USDC_PRECISION
break
default:
priceUsd = swap.affiliateAssetUsd
// Fee asset is neither sell nor buy nor a known native fee asset — precision unknown
Expand Down
4 changes: 2 additions & 2 deletions apps/swap-service/src/utils/affiliateFeeAsset.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AssetId } from '@shapeshiftoss/caip'
import { mayachainAssetId, thorchainAssetId } from '@shapeshiftoss/caip'
import { mayachainAssetId, thorchainAssetId, usdcAssetId } from '@shapeshiftoss/caip'
import { SwapperName } from '@shapeshiftoss/swapper'
import type { Asset } from '@shapeshiftoss/types'

Expand All @@ -13,7 +13,7 @@ const SWAPPER_FEE_STRATEGY: Record<SwapperName, FeeAssetStrategy> = {
[SwapperName.BobGateway]: null,
[SwapperName.ButterSwap]: 'buy_asset',
[SwapperName.Cetus]: 'buy_asset',
[SwapperName.Chainflip]: 'buy_asset',
[SwapperName.Chainflip]: usdcAssetId,
[SwapperName.CowSwap]: 'sell_asset',
[SwapperName.Debridge]: 'sell_asset',
[SwapperName.Mayachain]: mayachainAssetId,
Expand Down
214 changes: 214 additions & 0 deletions apps/swap-service/src/verification/__tests__/chainflip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import type { HttpService } from '@nestjs/axios'
import { of, throwError } from 'rxjs'

import type { SwapperSpecificMetadata } from '@shapeshiftoss/swapper'

import type { Swap } from '../../swaps/types'
import { SwapVerificationService } from '../swap-verification.service'

import chainflipResponse from './fixtures/chainflip/response.json'
import chainflipSwap from './fixtures/chainflip/swap'

const swap = chainflipSwap as unknown as Swap

const SHAPESHIFT_AFFILIATE_SS58 = 'cFMeDPtPHccVYdBSJKTtCYuy7rewFNpro3xZBKaCGbSS2xhRi'
const USDC_ASSET_ID = 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'

const makeHttpMock = (response: unknown): HttpService =>
({ post: jest.fn().mockReturnValue(of({ data: response })) }) as unknown as HttpService

const affiliateNode = (response: typeof chainflipResponse) => {
const node = response.data.swapRequest.beneficiaries.nodes.find((n) => n.type === 'AFFILIATE')
if (!node) throw new Error('fixture has no AFFILIATE beneficiary')
return node
}

describe('verifyChainflip', () => {
let service: SwapVerificationService

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

it('verifies a successful swap with matching affiliate broker and reports the USDC commission', async () => {
service = new SwapVerificationService(makeHttpMock(chainflipResponse))

const result = await service.verifySwap(swap)

expect(result).toMatchObject({
verificationStatus: 'SUCCESS',
hasAffiliate: true,
affiliateBps: 60,
affiliateAddress: SHAPESHIFT_AFFILIATE_SS58,
// executed swap input (deposit 3200000 minus the 94 ingress fee), not the gross deposit
verifiedSellAmountCryptoBaseUnit: '3199906',
actualBuyAmountCryptoBaseUnit: '1989934751',
actualAffiliateFeeAmountCryptoBaseUnit: '12010758',
actualAffiliateFeeAssetId: USDC_ASSET_ID,
})
})

it('verifies a DCA swap — multiple chunks aggregate into a single USDC commission group', async () => {
const response = structuredClone(chainflipResponse)
response.data.swapRequest.executedSwaps.aggregates.sum.swapInputAmount = '99990000'
response.data.swapRequest.egress.amount = '98952579'
affiliateNode(response).commissions.groupedAggregates = [
{ asset: ['Usdc'], sum: { amount: '367710212', valueUsd: '367.657975178100000000000000000000' } },
]

service = new SwapVerificationService(makeHttpMock(response))

const result = await service.verifySwap(swap)

expect(result.verificationStatus).toBe('SUCCESS')
expect(result.hasAffiliate).toBe(true)
expect(result.verifiedSellAmountCryptoBaseUnit).toBe('99990000')
expect(result.actualBuyAmountCryptoBaseUnit).toBe('98952579')
expect(result.actualAffiliateFeeAmountCryptoBaseUnit).toBe('367710212')
expect(result.actualAffiliateFeeAssetId).toBe(USDC_ASSET_ID)
})

it('reports the executed sell amount (not the gross deposit) on a partial FoK refund', async () => {
const response = structuredClone(chainflipResponse)
// deposit is 3200000; only 2000000 actually swapped, the remainder refunded
response.data.swapRequest.executedSwaps.aggregates.sum.swapInputAmount = '2000000'
response.data.swapRequest.egress.amount = '1200000000'

service = new SwapVerificationService(makeHttpMock(response))

const result = await service.verifySwap(swap)

expect(result.verificationStatus).toBe('SUCCESS')
expect(result.hasAffiliate).toBe(true)
expect(result.verifiedSellAmountCryptoBaseUnit).toBe('2000000')
expect(result.actualBuyAmountCryptoBaseUnit).toBe('1200000000')
})

it('matches the affiliate broker SS58 case-insensitively', async () => {
const response = structuredClone(chainflipResponse)
affiliateNode(response).account.idSs58 = SHAPESHIFT_AFFILIATE_SS58.toLowerCase()

service = new SwapVerificationService(makeHttpMock(response))

const result = await service.verifySwap(swap)

expect(result.hasAffiliate).toBe(true)
expect(result.affiliateBps).toBe(60)
})

it('returns hasAffiliate=false when the affiliate beneficiary is not our broker', async () => {
const response = structuredClone(chainflipResponse)
affiliateNode(response).account.idSs58 = 'cFSomeoneElsesAffiliateBroker000000000000000000000'

service = new SwapVerificationService(makeHttpMock(response))

const result = await service.verifySwap(swap)

expect(result.verificationStatus).toBe('SUCCESS')
expect(result.hasAffiliate).toBe(false)
expect(result.affiliateBps).toBeUndefined()
expect(result.affiliateAddress).toBeUndefined()
expect(result.actualAffiliateFeeAmountCryptoBaseUnit).toBeUndefined()
expect(result.actualAffiliateFeeAssetId).toBeUndefined()
expect(result.actualAffiliateAssetUsd).toBeUndefined()
expect(result.verifiedSellAmountCryptoBaseUnit).toBe('3199906')
expect(result.actualBuyAmountCryptoBaseUnit).toBe('1989934751')
})

it('returns hasAffiliate=false when there is no AFFILIATE beneficiary (submitter only)', async () => {
const response = structuredClone(chainflipResponse)
const nodes = response.data.swapRequest.beneficiaries.nodes
response.data.swapRequest.beneficiaries.nodes = nodes.filter((node) => node.type !== 'AFFILIATE')

service = new SwapVerificationService(makeHttpMock(response))

const result = await service.verifySwap(swap)

expect(result.hasAffiliate).toBe(false)
expect(result.affiliateBps).toBeUndefined()
})

// Empty commission arises two ways: (1) the BaaS-Confirmed-early vs explorer-indexing race, and
// (2) a fully-refunded FoK swap (no swap executed → no commission). Both resolve to PENDING here;
// case (2) never actually reaches verification (no swap egress → stays tx-status PENDING).
it('returns PENDING when the affiliate commission is empty (indexing race / full refund)', async () => {
const response = structuredClone(chainflipResponse)
affiliateNode(response).commissions.groupedAggregates = []

service = new SwapVerificationService(makeHttpMock(response))

const result = await service.verifySwap(swap)

expect(result.verificationStatus).toBe('PENDING')
expect(result.hasAffiliate).toBe(false)
expect(result.noAffiliateReason).toContain('commission not yet indexed')
})

it('returns FAILED when the affiliate commission spans multiple asset groups', async () => {
const response = structuredClone(chainflipResponse)
affiliateNode(response).commissions.groupedAggregates = [
{ asset: ['Usdc'], sum: { amount: '12010758', valueUsd: '12.0' } },
{ asset: ['Usdt'], sum: { amount: '5000000', valueUsd: '5.0' } },
]

service = new SwapVerificationService(makeHttpMock(response))

const result = await service.verifySwap(swap)

expect(result.verificationStatus).toBe('FAILED')
expect(result.hasAffiliate).toBe(false)
expect(result.noAffiliateReason).toContain('2 group(s)')
})

it('returns FAILED when the affiliate commission is in an unmapped (non-USDC) asset', async () => {
const response = structuredClone(chainflipResponse)
affiliateNode(response).commissions.groupedAggregates = [
{ asset: ['Usdt'], sum: { amount: '5000000', valueUsd: '5.0' } },
]

service = new SwapVerificationService(makeHttpMock(response))

const result = await service.verifySwap(swap)

expect(result.verificationStatus).toBe('FAILED')
expect(result.hasAffiliate).toBe(false)
expect(result.noAffiliateReason).toContain('Unexpected Chainflip commission')
expect(result.noAffiliateReason).toContain('Usdt')
})

it('returns FAILED when metadata.chainflipSwapId is missing', async () => {
const swapWithoutMetadata = { ...swap, metadata: {} as SwapperSpecificMetadata } as Swap

service = new SwapVerificationService(makeHttpMock(chainflipResponse))

const result = await service.verifySwap(swapWithoutMetadata)

expect(result).toMatchObject({
verificationStatus: 'FAILED',
hasAffiliate: false,
noAffiliateReason: 'Missing chainflipSwapId in metadata',
})
})

it('returns PENDING when the explorer returns a null swapRequest (indexer lag — retry next tick)', async () => {
service = new SwapVerificationService(makeHttpMock({ data: { swapRequest: null } }))

const result = await service.verifySwap(swap)

expect(result.verificationStatus).toBe('PENDING')
expect(result.noAffiliateReason).toBe('No swap request found from Chainflip explorer')
})

it('returns PENDING when the HTTP call fails (transient — retry next tick)', async () => {
const httpMock = {
post: 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')
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
{
"data": {
"swapRequest": {
"executedSwaps": {
"aggregates": {
"sum": {
"swapInputAmount": "3199906"
}
}
},
"egress": {
"amount": "1989934751"
},
"beneficiaries": {
"nodes": [
{
"type": "SUBMITTER",
"brokerCommissionRateBps": 5,
"commissions": {
"groupedAggregates": [
{
"asset": [
"Usdc"
],
"sum": {
"amount": "1000896",
"valueUsd": "1.000686756100000000000000000000"
}
}
]
},
"account": {
"idSs58": "cFJZVRaybb2PBwxTiAiRLiQfHY4KPB3RpJK22Q7Fhqk979aCH"
}
},
{
"type": "AFFILIATE",
"brokerCommissionRateBps": 60,
"commissions": {
"groupedAggregates": [
{
"asset": [
"Usdc"
],
"sum": {
"amount": "12010758",
"valueUsd": "12.008247071600000000000000000000"
}
}
]
},
"account": {
"idSs58": "cFMeDPtPHccVYdBSJKTtCYuy7rewFNpro3xZBKaCGbSS2xhRi"
}
}
]
}
}
}
}
Loading
Loading