diff --git a/.gitignore b/.gitignore index 9d050b3..d739ec3 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ node_modules # Claude Code (local-only) .claude/settings.local.json .claude/worktrees/ +docs/superpowers/ diff --git a/apps/swap-service/src/env.ts b/apps/swap-service/src/env.ts index 749d489..f4208ac 100644 --- a/apps/swap-service/src/env.ts +++ b/apps/swap-service/src/env.ts @@ -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', diff --git a/apps/swap-service/src/swaps/swaps.service.ts b/apps/swap-service/src/swaps/swaps.service.ts index 79358fc..d48de83 100644 --- a/apps/swap-service/src/swaps/swaps.service.ts +++ b/apps/swap-service/src/swaps/swaps.service.ts @@ -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, diff --git a/apps/swap-service/src/swaps/utils.ts b/apps/swap-service/src/swaps/utils.ts index 098957d..f3e9aeb 100644 --- a/apps/swap-service/src/swaps/utils.ts +++ b/apps/swap-service/src/swaps/utils.ts @@ -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' @@ -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. @@ -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 diff --git a/apps/swap-service/src/utils/affiliateFeeAsset.ts b/apps/swap-service/src/utils/affiliateFeeAsset.ts index 06fb5a9..f6db461 100644 --- a/apps/swap-service/src/utils/affiliateFeeAsset.ts +++ b/apps/swap-service/src/utils/affiliateFeeAsset.ts @@ -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' @@ -13,7 +13,7 @@ const SWAPPER_FEE_STRATEGY: Record = { [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, diff --git a/apps/swap-service/src/verification/__tests__/chainflip.test.ts b/apps/swap-service/src/verification/__tests__/chainflip.test.ts new file mode 100644 index 0000000..6671311 --- /dev/null +++ b/apps/swap-service/src/verification/__tests__/chainflip.test.ts @@ -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') + }) +}) diff --git a/apps/swap-service/src/verification/__tests__/fixtures/chainflip/response.json b/apps/swap-service/src/verification/__tests__/fixtures/chainflip/response.json new file mode 100644 index 0000000..0fa895c --- /dev/null +++ b/apps/swap-service/src/verification/__tests__/fixtures/chainflip/response.json @@ -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" + } + } + ] + } + } + } +} diff --git a/apps/swap-service/src/verification/__tests__/fixtures/chainflip/swap.ts b/apps/swap-service/src/verification/__tests__/fixtures/chainflip/swap.ts new file mode 100644 index 0000000..a1c1247 --- /dev/null +++ b/apps/swap-service/src/verification/__tests__/fixtures/chainflip/swap.ts @@ -0,0 +1,84 @@ +import { SwapperName } from '@shapeshiftoss/swapper' + +import type { Swap } from '../../../../swaps/types' + +export default { + swapId: 'c1f10208-1640-4208-9c1f-100000001640', + sellAsset: { + icon: 'https://assets.coincap.io/assets/icons/btc@2x.png', + name: 'Bitcoin', + color: '#FF9800', + symbol: 'BTC', + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + chainId: 'bip122:000000000019d6689c085ae165831e93', + explorer: 'https://live.blockcypher.com', + precision: 8, + networkName: 'Bitcoin', + explorerTxLink: 'https://live.blockcypher.com/btc/tx/', + relatedAssetKey: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + explorerAddressLink: 'https://live.blockcypher.com/btc/address/', + }, + buyAsset: { + icon: 'https://assets.coingecko.com/coins/images/325/large/Tether.png', + name: 'Tether USD', + color: '#26A17B', + symbol: 'USDT', + assetId: 'eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7', + chainId: 'eip155:1', + explorer: 'https://etherscan.io', + precision: 6, + networkName: 'Ethereum', + explorerTxLink: 'https://etherscan.io/tx/', + relatedAssetKey: 'eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7', + explorerAddressLink: 'https://etherscan.io/address/', + }, + sellAmountCryptoBaseUnit: '3200000', + expectedBuyAmountCryptoBaseUnit: '1989934751', + actualBuyAmountCryptoBaseUnit: '1989934751', + status: 'SUCCESS', + source: 'Chainflip', + swapperName: SwapperName.Chainflip, + sellAccountId: 'bc1qzescq0mtv5vqlzzlxadg8fgq5l2grnzwv4tpep', + buyAccountId: '0xb4698b54fde6c23614e6472e76ecbc9b678aa71b', + receiveAddress: '0xb4698b54fde6c23614e6472e76ecbc9b678aa71b', + sellTxHash: '552ebfd90b12b22335d9e362a7987117fbf3adabc65771f781b1adb66e3cf396', + buyTxHash: '0x8f06d1400cb3b1ea3cef58d18e3f78c52081843d303b08b7f33150976329cc4d', + txLink: null, + statusMessage: 'Swap complete', + isStreaming: false, + createdAt: new Date('2026-07-05T10:31:30.000Z'), + updatedAt: new Date('2026-07-05T10:34:48.001Z'), + metadata: { + quoteId: 'c1f10208-1640-4208-9c1f-100000001640', + stepIndex: 0, + relayTransactionMetadata: undefined, + acrossTransactionMetadata: undefined, + chainflipSwapId: '1640208', + debridgeTransactionMetadata: undefined, + relayerExplorerTxLink: undefined, + relayerTxHash: undefined, + streamingSwapMetadata: undefined, + nearIntentsSpecific: undefined, + }, + userId: 'api', + referralCode: null, + sellAssetUsd: '62646.88', + buyAssetUsd: '1.0007', + affiliateAssetUsd: '1.0', + isAffiliateVerified: true, + verificationStatus: 'SUCCESS', + affiliateVerificationDetails: { + affiliateBps: 60, + hasAffiliate: true, + affiliateAddress: 'cFMeDPtPHccVYdBSJKTtCYuy7rewFNpro3xZBKaCGbSS2xhRi', + verifiedSellAmountCryptoBaseUnit: '3199906', + }, + partnerAddress: null, + partnerCode: null, + affiliateBps: 60, + partnerBps: 0, + origin: 'api', + affiliateFeeAssetId: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + actualAffiliateFeeAmountCryptoBaseUnit: '12010758', + shapeshiftBps: 60, +} satisfies Swap diff --git a/apps/swap-service/src/verification/chainflip.query.ts b/apps/swap-service/src/verification/chainflip.query.ts new file mode 100644 index 0000000..463cab1 --- /dev/null +++ b/apps/swap-service/src/verification/chainflip.query.ts @@ -0,0 +1,41 @@ +// Minimal projection of the Chainflip explorer's `GetSwapByNativeId` operation — only the fields the +// verifier consumes. `swapRequestByNativeId` is keyed by the swap-request nativeId we store as +// `metadata.chainflipSwapId`. Affiliate attribution + realized commission live under `beneficiaries`; +// commission is summed per-asset via `groupedAggregates(groupBy: ASSET)` so DCA chunks aggregate into +// one group. `executedSwaps` sums the actually-swapped input (excludes any refunded portion). +export const GET_SWAP_BY_NATIVE_ID_OPERATION = 'GetSwapByNativeId' + +export const GET_SWAP_BY_NATIVE_ID_QUERY = `query GetSwapByNativeId($nativeId: BigInt!) { + swapRequest: swapRequestByNativeId(nativeId: $nativeId) { + executedSwaps: swapsBySwapRequestId( + filter: { swapExecutedEventId: { isNull: false }, type: { notEqualTo: GAS } } + ) { + aggregates { + sum { + swapInputAmount + } + } + } + egress: egressByEgressId { + amount + } + beneficiaries: swapRequestBeneficiariesBySwapRequestId(orderBy: TYPE_ASC) { + nodes { + type + brokerCommissionRateBps + commissions: swapCommissionsBySwapRequestBeneficiaryId { + groupedAggregates(groupBy: ASSET) { + asset: keys + sum { + amount + valueUsd + } + } + } + account: accountByAccountId { + idSs58 + } + } + } + } +}` diff --git a/apps/swap-service/src/verification/swap-verification.service.ts b/apps/swap-service/src/verification/swap-verification.service.ts index 10e9262..4813fc7 100644 --- a/apps/swap-service/src/verification/swap-verification.service.ts +++ b/apps/swap-service/src/verification/swap-verification.service.ts @@ -4,6 +4,7 @@ import { Injectable, Logger } from '@nestjs/common' import { firstValueFrom } from 'rxjs' import { SwapVerificationResult } from '@shapeshift/shared-types' +import { usdcAssetId } from '@shapeshiftoss/caip' import { assertGetCowNetwork, getTreasuryAddressFromChainId, @@ -15,12 +16,13 @@ import { env } from '../env' import type { Swap } from '../swaps/types' import { getAssetPriceUsd } from '../utils/pricing' +import { GET_SWAP_BY_NATIVE_ID_OPERATION, GET_SWAP_BY_NATIVE_ID_QUERY } from './chainflip.query' import { AcrossDepositStatusResponse, BebopTrade, BebopTradesResponse, ButterBridgeInfoApiResponse, - ChainflipSwapResponse, + ChainflipExplorerResponse, CowSwapAppDataResponse, CowSwapDecodedAppData, CowSwapOrderResponse, @@ -40,15 +42,12 @@ export class SwapVerificationService { private readonly shapeshift0xIntegrator = 'ShapeShift' private readonly shapeshiftBebopSource = 'shapeshift' private readonly shapeshiftButterswapEntrance = 'shapeshift' - private readonly shapeshiftChainflipAffiliate = 'shapeshift' private readonly shapeshiftCowswapAppCode = 'shapeshift' private readonly bebopApiKey = env.VITE_BEBOP_API_KEY - private readonly chainflipApiKey = env.VITE_CHAINFLIP_API_KEY private readonly acrossApiUrl = env.VITE_ACROSS_API_URL private readonly bebopApiUrl = env.VITE_BEBOP_API_URL - private readonly chainflipApiUrl = env.VITE_CHAINFLIP_API_URL private readonly cowswapApiUrl = env.VITE_COWSWAP_BASE_URL private readonly portalsApiUrl = env.VITE_PORTALS_BASE_URL private readonly zrxApiUrl = env.VITE_ZRX_BASE_URL @@ -73,7 +72,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: @@ -198,7 +197,7 @@ export class SwapVerificationService { } })() - const actualAffiliateFeeUsd = await (async () => { + const actualAffiliateAssetUsd = await (async () => { if (!actualAffiliateFeeAssetId) return if (actualAffiliateFeeAssetId === swap.sellAsset.assetId) return swap.sellAssetUsd @@ -218,7 +217,7 @@ export class SwapVerificationService { actualBuyAmountCryptoBaseUnit: request.data.metadata?.currencyOut?.amount, actualAffiliateFeeAmountCryptoBaseUnit: shapeshiftFee?.amount, actualAffiliateFeeAssetId, - actualAffiliateFeeUsd: actualAffiliateFeeUsd ?? undefined, + actualAffiliateAssetUsd: actualAffiliateAssetUsd ?? undefined, } } @@ -337,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', @@ -400,42 +399,63 @@ export class SwapVerificationService { private async verifyChainflip(swap: Swap): Promise { const metadata = swap.metadata as Record - const chainflipSwapId = metadata?.chainflipSwapId as string | undefined + const chainflipSwapId = metadata?.chainflipSwapId as string | undefined if (!chainflipSwapId) return noAffiliateResult('FAILED', 'Missing chainflipSwapId in metadata') - const statusUrl = `${this.chainflipApiUrl}/swaps/${chainflipSwapId}` + const { data } = await firstValueFrom( + this.httpService.post(env.VITE_CHAINFLIP_EXPLORER_URL, { + operationName: GET_SWAP_BY_NATIVE_ID_OPERATION, + query: GET_SWAP_BY_NATIVE_ID_QUERY, + variables: { nativeId: chainflipSwapId }, + }), + ) - const headers: Record = {} - if (this.chainflipApiKey) { - headers['Authorization'] = `Bearer ${this.chainflipApiKey}` + if (data?.errors?.length) { + this.logger.error( + `Chainflip explorer returned errors for swap ${chainflipSwapId}: ${JSON.stringify(data.errors)}`, + ) } - const response = await firstValueFrom(this.httpService.get(statusUrl, { headers })) + const swapRequest = data?.data?.swapRequest + if (!swapRequest) return noAffiliateResult('PENDING', 'No swap request found from Chainflip explorer') - const swapData = response.data + const affiliate = (swapRequest.beneficiaries?.nodes ?? []).find( + (beneficiary) => + beneficiary.type === 'AFFILIATE' && + beneficiary.account?.idSs58?.toLowerCase() === + 'cFMeDPtPHccVYdBSJKTtCYuy7rewFNpro3xZBKaCGbSS2xhRi'.toLowerCase(), + ) - if (!swapData) return noAffiliateResult('PENDING', 'No swap data found from Chainflip API') + const commissions = affiliate?.commissions?.groupedAggregates ?? [] + const commission = commissions[0] + const commissionAsset = commission?.asset?.[0] - const affiliate = swapData.affiliate || swapData.affiliateName - const affiliateBps = swapData.affiliateBps || swapData.affiliateFee + const hasAffiliate = Boolean(affiliate) - const hasShapeshiftAffiliate = affiliate?.toLowerCase() === this.shapeshiftChainflipAffiliate.toLowerCase() + if (hasAffiliate) { + if (commissions.length === 0) { + return noAffiliateResult('PENDING', `Chainflip commission not yet indexed for swap ${chainflipSwapId}`) + } - const verifiedSellAmountCryptoBaseUnit = ( - swapData.depositAmount ?? - swapData.ingressAmount ?? - swapData.sourceAmount - )?.toString() + if (commissions.length !== 1 || commissionAsset?.toLowerCase() !== 'usdc') { + return noAffiliateResult( + 'FAILED', + `Unexpected Chainflip commission for swap ${chainflipSwapId}: ${commissions.length} group(s), asset "${commissionAsset ?? 'none'}"`, + ) + } + } return { verificationStatus: 'SUCCESS', - hasAffiliate: hasShapeshiftAffiliate, - affiliateBps: hasShapeshiftAffiliate && affiliateBps ? parseInt(String(affiliateBps)) : undefined, - affiliateAddress: hasShapeshiftAffiliate ? this.shapeshiftChainflipAffiliate : undefined, - verifiedSellAmountCryptoBaseUnit, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, + hasAffiliate, + affiliateBps: hasAffiliate ? affiliate?.brokerCommissionRateBps : undefined, + affiliateAddress: hasAffiliate ? affiliate?.account?.idSs58 : undefined, + // Amount actually swapped (excludes any refunded portion on partial FoK fills), not the gross deposit. + verifiedSellAmountCryptoBaseUnit: swapRequest.executedSwaps?.aggregates?.sum?.swapInputAmount, + actualBuyAmountCryptoBaseUnit: swapRequest.egress?.amount, + actualAffiliateFeeAmountCryptoBaseUnit: hasAffiliate ? commission?.sum?.amount : undefined, + actualAffiliateFeeAssetId: hasAffiliate ? usdcAssetId : undefined, } } diff --git a/apps/swap-service/src/verification/types.ts b/apps/swap-service/src/verification/types.ts index 38ee315..f18ea4a 100644 --- a/apps/swap-service/src/verification/types.ts +++ b/apps/swap-service/src/verification/types.ts @@ -122,14 +122,50 @@ export interface PortalsOrderResponse { } } -export interface ChainflipSwapResponse { - affiliate?: string - affiliateName?: string - affiliateBps?: string - affiliateFee?: string - depositAmount?: string - ingressAmount?: string - sourceAmount?: string +// Shape of the (trimmed) Chainflip explorer `GetSwapByNativeId` response — see chainflip.query.ts. +// Only the fields the verifier consumes are fetched and typed. +export interface ChainflipCommissionAggregate { + // `keys` from groupBy: ASSET — a single-element array of the Chainflip asset symbol, e.g. ["Usdc"]. + asset?: string[] + sum?: { + amount?: string + valueUsd?: string + } +} + +export interface ChainflipBeneficiary { + type?: string + brokerCommissionRateBps?: number + commissions?: { + groupedAggregates?: ChainflipCommissionAggregate[] + } + account?: { + idSs58?: string + } +} + +export interface ChainflipSwapRequest { + // Sum of the amounts actually swapped (excludes any refunded portion), in the source asset base unit. + executedSwaps?: { + aggregates?: { + sum?: { + swapInputAmount?: string + } + } + } + egress?: { + amount?: string + } + beneficiaries?: { + nodes?: ChainflipBeneficiary[] + } +} + +export interface ChainflipExplorerResponse { + data?: { + swapRequest?: ChainflipSwapRequest | null + } + errors?: Array<{ message?: string }> } export interface ZrxTrade { diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 301bb8e..e001da9 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -124,7 +124,7 @@ export interface SwapVerificationResult { actualBuyAmountCryptoBaseUnit: string | undefined actualAffiliateFeeAmountCryptoBaseUnit: string | undefined actualAffiliateFeeAssetId?: string - actualAffiliateFeeUsd?: string + actualAffiliateAssetUsd?: string noAffiliateReason?: string } diff --git a/scripts/affiliate-payouts/utils.ts b/scripts/affiliate-payouts/utils.ts index b71cc5d..077edec 100644 --- a/scripts/affiliate-payouts/utils.ts +++ b/scripts/affiliate-payouts/utils.ts @@ -161,7 +161,9 @@ export function aggregateByPartner( // Pay only on the verified on-chain fee, via the shared exact partner-share helper. accrual.swapCount += 1 accrual.volumeUsd = accrual.volumeUsd.plus(fee.volumeUsd) - accrual.feesEarnedUsd = accrual.feesEarnedUsd.plus(deps.getPartnerFeeUsd(fee.actualFeeUsd, fee.verifiedBps, row.partnerBps)) + accrual.feesEarnedUsd = accrual.feesEarnedUsd.plus( + deps.getPartnerFeeUsd(fee.actualFeeUsd, fee.verifiedBps, row.partnerBps), + ) partners.set(partnerCode.toLowerCase(), accrual) }