diff --git a/apps/swap-service/src/polling/swap-polling.service.ts b/apps/swap-service/src/polling/swap-polling.service.ts index ae1c178..10ac88e 100644 --- a/apps/swap-service/src/polling/swap-polling.service.ts +++ b/apps/swap-service/src/polling/swap-polling.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common' import { Cron, CronExpression } from '@nestjs/schedule' import { SwapsService } from '../swaps/swaps.service' +import type { Swap } from '../swaps/types' import { WebsocketGateway } from '../websocket/websocket.gateway' const POLL_CONCURRENCY = 10 @@ -10,7 +11,8 @@ const POLL_CONCURRENCY = 10 export class SwapPollingService { private readonly logger = new Logger(SwapPollingService.name) - private isPolling = false + private isPollingTx = false + private isPollingVerification = false constructor( private swapsService: SwapsService, @@ -18,49 +20,61 @@ export class SwapPollingService { ) {} @Cron(CronExpression.EVERY_5_SECONDS) - async pollPendingSwaps() { - if (this.isPolling) return - this.isPolling = true + async pollPendingTxStatus() { + if (this.isPollingTx) return + this.isPollingTx = true try { - // TODO: paginate with a batch size + oldest-first ordering once the in-flight - // queue grows enough that one cron tick can't drain it within the 5s interval. - const pendingSwaps = await this.swapsService.getPendingSwaps() - if (pendingSwaps.length === 0) return - - this.logger.log(`Polling ${pendingSwaps.length} pending swaps`) - - const queue = [...pendingSwaps] - const workers = Array.from({ length: Math.min(POLL_CONCURRENCY, queue.length) }, async () => { - while (queue.length > 0) { - const swap = queue.shift() - if (swap) await this.pollOne(swap) - } - }) - await Promise.all(workers) + const swaps = await this.swapsService.getPendingTxSwaps() + if (swaps.length === 0) return + + this.logger.log(`Polling tx status for ${swaps.length} swaps`) + await this.runWorkers(swaps, (swap) => this.pollTxStatus(swap)) } catch (err) { - this.logger.error('Failed to poll pending swaps:', err) + this.logger.error('Failed to poll pending tx status:', err) } finally { - this.isPolling = false + this.isPollingTx = false } } - private async pollOne(swap: Awaited>[number]): Promise { - const statusUpdate = await (async () => { - try { - return await this.swapsService.pollSwapStatus(swap.swapId) - } catch (err) { - this.logger.error(`Failed to poll swap ${swap.swapId}:`, err) - return - } - })() + @Cron(CronExpression.EVERY_30_SECONDS) + async pollPendingVerification() { + if (this.isPollingVerification) return + this.isPollingVerification = true - if (!statusUpdate || statusUpdate.status === swap.status) return + try { + const swaps = await this.swapsService.getPendingVerificationSwaps() + if (swaps.length === 0) return - this.logger.log(`Status changed for swap ${swap.swapId}: ${swap.status} -> ${statusUpdate.status}`) + this.logger.log(`Polling verification for ${swaps.length} swaps`) + await this.runWorkers(swaps, (swap) => this.pollVerification(swap)) + } catch (err) { + this.logger.error('Failed to poll pending verification:', err) + } finally { + this.isPollingVerification = false + } + } + + private async runWorkers(swaps: Swap[], handler: (swap: Swap) => Promise): Promise { + const queue = [...swaps] + const workers = Array.from({ length: Math.min(POLL_CONCURRENCY, queue.length) }, async () => { + while (queue.length > 0) { + const swap = queue.shift() + if (swap) await handler(swap) + } + }) + await Promise.all(workers) + } + private async pollTxStatus(swap: Swap): Promise { try { - const updatedSwap = await this.swapsService.updateSwapStatus({ + const statusUpdate = await this.swapsService.checkSwapStatus(swap.swapId) + + if (statusUpdate.status === swap.status) return + + this.logger.log(`Status changed for swap ${swap.swapId}: ${swap.status} -> ${statusUpdate.status}`) + + const updated = await this.swapsService.updateSwapStatus({ swapId: swap.swapId, status: statusUpdate.status, sellTxHash: statusUpdate.sellTxHash, @@ -68,9 +82,22 @@ export class SwapPollingService { statusMessage: statusUpdate.statusMessage, }) - this.websocketGateway.sendSwapUpdateToUser(swap.userId, updatedSwap) - } catch (error) { - this.logger.error(`Failed to persist status change for swap ${swap.swapId}:`, error) + this.websocketGateway.sendSwapUpdateToUser(updated.userId, updated) + } catch (err) { + this.logger.error(`Failed to poll tx status for swap ${swap.swapId}:`, err) + } + } + + private async pollVerification(swap: Swap): Promise { + try { + const updated = await this.swapsService.verifySwap(swap) + if (updated.verificationStatus !== swap.verificationStatus) { + this.logger.log( + `Verification changed for swap ${swap.swapId}: ${swap.verificationStatus} -> ${updated.verificationStatus}`, + ) + } + } catch (err) { + this.logger.error(`Failed to verify swap ${swap.swapId}:`, err) } } } diff --git a/apps/swap-service/src/swaps/swaps.controller.ts b/apps/swap-service/src/swaps/swaps.controller.ts index 2bf6094..ab67b4a 100644 --- a/apps/swap-service/src/swaps/swaps.controller.ts +++ b/apps/swap-service/src/swaps/swaps.controller.ts @@ -16,11 +16,6 @@ export class SwapsController { return this.swapsService.createSwap(data) } - @Get('pending') - async getPendingSwaps() { - return this.swapsService.getPendingSwaps() - } - @Get(':swapId') async getSwapById(@Param('swapId') swapId: string) { const swap = await this.swapsService.getSwapById(swapId) diff --git a/apps/swap-service/src/swaps/swaps.service.ts b/apps/swap-service/src/swaps/swaps.service.ts index a63c89f..892553f 100644 --- a/apps/swap-service/src/swaps/swaps.service.ts +++ b/apps/swap-service/src/swaps/swaps.service.ts @@ -183,11 +183,11 @@ export class SwapsService { try { await this.sendStatusUpdateNotification(swap) - } catch { - logger.error(`Failed to send notification for swap ${swap.swapId}`) + } catch (err) { + logger.error(`Failed to send notification for swap ${swap.swapId}:`, err) } - logger.log(`Swap status updated: ${swap.swapId} -> ${swap.status}`) + logger.log(`Swap status updated for swap: ${swap.swapId} (${swap.status})`) return swap } catch (error) { @@ -232,9 +232,24 @@ export class SwapsService { return { swaps: rows.map(toSwap), nextCursor: getNextCursor(rows, limit) } } - async getPendingSwaps(): Promise { + async getPendingTxSwaps(): Promise { const swaps = await this.prisma.swap.findMany({ - where: { status: { in: ['IDLE', 'PENDING'] }, sellTxHash: { not: null } }, + where: { + sellTxHash: { not: null }, + status: { in: ['IDLE', 'PENDING'] }, + }, + }) + + return swaps.map(toSwap) + } + + async getPendingVerificationSwaps(): Promise { + const swaps = await this.prisma.swap.findMany({ + where: { + sellTxHash: { not: null }, + verificationStatus: 'PENDING', + status: { in: ['SUCCESS', 'FAILED'] }, + }, }) return swaps.map(toSwap) @@ -347,8 +362,8 @@ export class SwapsService { } } - async pollSwapStatus(swapId: string): Promise { - logger.log(`Polling status for swap: ${swapId}`) + async checkSwapStatus(swapId: string): Promise { + logger.log(`Checking status for swap: ${swapId}`) const prismaSwap = await this.prisma.swap.findUnique({ where: { swapId } }) if (!prismaSwap) throw new NotFoundException(`Swap not found: ${swapId}`) @@ -374,8 +389,6 @@ export class SwapsService { const statusMessage = Array.isArray(message) ? message[0] : message - await this.reconcileSwap(swap) - return { status: status === TxStatus.Confirmed ? 'SUCCESS' : status === TxStatus.Failed ? 'FAILED' : 'PENDING', sellTxHash: swap.sellTxHash, @@ -383,7 +396,7 @@ export class SwapsService { statusMessage: typeof statusMessage === 'string' ? statusMessage : '', } } catch (error) { - logger.error(`Failed to poll swap status for ${swapId}:`, error) + logger.error(`Failed to check swap status for ${swapId}:`, error) return { status: 'PENDING', statusMessage: `Error polling status: ${error instanceof Error ? error.message : 'Unknown error'}`, @@ -391,34 +404,33 @@ export class SwapsService { } } - private async reconcileSwap(swap: Swap): Promise { - try { - const verificationResult = await this.swapVerificationService.verifySwap(swap) - - logger.log( - [ - `Swap verified: ${swap.swapId}`, - verificationResult.isVerified ? 'ok' : 'failed', - verificationResult.hasAffiliate && - `affiliate ${verificationResult.affiliateAddress} (${verificationResult.affiliateBps} bps)`, - verificationResult.error && `error: ${verificationResult.error}`, - ] - .filter(Boolean) - .join(' | '), + async verifySwap(swap: Swap): Promise { + if (swap.status === 'FAILED') { + return toSwap( + await this.prisma.swap.update({ + where: { swapId: swap.swapId }, + data: { verificationStatus: 'FAILED', isAffiliateVerified: false }, + }), ) + } - const isAffiliateVerified = verificationResult.isVerified && verificationResult.hasAffiliate + const verificationResult = await this.swapVerificationService.verifySwap(swap) + if (verificationResult.verificationStatus === 'PENDING') return swap - const affiliateVerificationDetails: AffiliateVerificationDetails = { - hasAffiliate: verificationResult.hasAffiliate, - affiliateBps: verificationResult.affiliateBps, - affiliateAddress: verificationResult.affiliateAddress, - verifiedSellAmountCryptoBaseUnit: verificationResult.verifiedSellAmountCryptoBaseUnit, - } + const isAffiliateVerified = verificationResult.verificationStatus === 'SUCCESS' && verificationResult.hasAffiliate + const affiliateVerificationDetails: AffiliateVerificationDetails = { + hasAffiliate: verificationResult.hasAffiliate, + affiliateBps: verificationResult.affiliateBps, + affiliateAddress: verificationResult.affiliateAddress, + verifiedSellAmountCryptoBaseUnit: verificationResult.verifiedSellAmountCryptoBaseUnit, + } + + return toSwap( await this.prisma.swap.update({ where: { swapId: swap.swapId }, data: { + verificationStatus: verificationResult.verificationStatus, isAffiliateVerified, affiliateFeeAssetId: verificationResult.actualAffiliateFeeAssetId, affiliateAssetUsd: verificationResult.actualAffiliateFeeUsd, @@ -426,9 +438,7 @@ export class SwapsService { actualBuyAmountCryptoBaseUnit: verificationResult.actualBuyAmountCryptoBaseUnit, actualAffiliateFeeAmountCryptoBaseUnit: verificationResult.actualAffiliateFeeAmountCryptoBaseUnit, }, - }) - } catch (error) { - logger.warn(`Failed to verify affiliate for swap ${swap.swapId}:`, error) - } + }), + ) } } diff --git a/apps/swap-service/src/verification/__tests__/fixtures/near/swap.ts b/apps/swap-service/src/verification/__tests__/fixtures/near/swap.ts index 61dbf59..f2fd508 100644 --- a/apps/swap-service/src/verification/__tests__/fixtures/near/swap.ts +++ b/apps/swap-service/src/verification/__tests__/fixtures/near/swap.ts @@ -71,6 +71,7 @@ export default { affiliateAssetUsd: '2288.4', isAffiliateVerified: null, affiliateVerificationDetails: null, + verificationStatus: 'PENDING', affiliateAddress: '0xa44c286ba83bb771cd0107b2c1df678435bd1535', affiliateBps: 60, origin: 'api', diff --git a/apps/swap-service/src/verification/__tests__/fixtures/relay/swap.ts b/apps/swap-service/src/verification/__tests__/fixtures/relay/swap.ts index 15d7174..6c2edc4 100644 --- a/apps/swap-service/src/verification/__tests__/fixtures/relay/swap.ts +++ b/apps/swap-service/src/verification/__tests__/fixtures/relay/swap.ts @@ -35,7 +35,7 @@ export default { sellAmountCryptoBaseUnit: '1000000000000000', expectedBuyAmountCryptoBaseUnit: '2272662', actualBuyAmountCryptoBaseUnit: '2272662', - status: 'PENDING', + status: 'SUCCESS', source: 'Relay', swapperName: SwapperName.Relay, sellAccountId: '3c70e97c6f86a5b5cfdf82dfd3380ac4ed3d8b89dabf86f631bdf739372926be', @@ -74,6 +74,7 @@ export default { buyAssetUsd: '0.9998', affiliateAssetUsd: '0.9998', isAffiliateVerified: true, + verificationStatus: 'SUCCESS', affiliateVerificationDetails: { affiliateBps: 60, hasAffiliate: true, diff --git a/apps/swap-service/src/verification/__tests__/near.test.ts b/apps/swap-service/src/verification/__tests__/near.test.ts index 04801ab..40ae2ce 100644 --- a/apps/swap-service/src/verification/__tests__/near.test.ts +++ b/apps/swap-service/src/verification/__tests__/near.test.ts @@ -26,7 +26,7 @@ describe('verifyNearIntents', () => { const result = await service.verifySwap(swap) expect(result).toMatchObject({ - isVerified: true, + verificationStatus: 'SUCCESS', hasAffiliate: true, affiliateBps: 30, affiliateAddress: 'shapeshifttokenomics.sputnik-dao.near', @@ -56,7 +56,7 @@ describe('verifyNearIntents', () => { const result = await service.verifySwap(swap) - expect(result.isVerified).toBe(true) + expect(result.verificationStatus).toBe('SUCCESS') expect(result.hasAffiliate).toBe(false) expect(result.affiliateBps).toBeUndefined() expect(result.affiliateAddress).toBeUndefined() @@ -104,24 +104,24 @@ describe('verifyNearIntents', () => { expect(result.verifiedSellAmountCryptoBaseUnit).toBe(response.quoteResponse.quote.amountIn) }) - it('returns unverified when nearIntentsSpecific.depositAddress is missing', async () => { + it('returns FAILED when nearIntentsSpecific.depositAddress is missing', async () => { const swapWithoutMetadata = { ...swap, metadata: {} as SwapperSpecificMetadata } as Swap const result = await service.verifySwap(swapWithoutMetadata) expect(result).toMatchObject({ - isVerified: false, + verificationStatus: 'FAILED', hasAffiliate: false, - error: 'Missing depositAddress in nearIntentsSpecific metadata', + noAffiliateReason: 'Missing depositAddress in nearIntentsSpecific metadata', }) }) - it('returns unverified when the SDK throws', async () => { + it('returns PENDING when the SDK throws (transient — retry next tick)', async () => { jest.spyOn(OneClickService, 'getExecutionStatus').mockRejectedValue(new Error('upstream 500')) const result = await service.verifySwap(swap) - expect(result.isVerified).toBe(false) - expect(result.error).toBe('upstream 500') + expect(result.verificationStatus).toBe('PENDING') + expect(result.noAffiliateReason).toBe('upstream 500') }) }) diff --git a/apps/swap-service/src/verification/__tests__/relay.test.ts b/apps/swap-service/src/verification/__tests__/relay.test.ts index 426b9d4..b4e3dc0 100644 --- a/apps/swap-service/src/verification/__tests__/relay.test.ts +++ b/apps/swap-service/src/verification/__tests__/relay.test.ts @@ -29,7 +29,7 @@ describe('verifyRelay', () => { const result = await service.verifySwap(swap) expect(result).toMatchObject({ - isVerified: true, + verificationStatus: 'SUCCESS', hasAffiliate: true, affiliateBps: 60, affiliateAddress: DAO_TREASURY_BASE, @@ -77,7 +77,7 @@ describe('verifyRelay', () => { const result = await service.verifySwap(swap) - expect(result.isVerified).toBe(true) + expect(result.verificationStatus).toBe('SUCCESS') expect(result.hasAffiliate).toBe(false) expect(result.affiliateBps).toBeUndefined() expect(result.affiliateAddress).toBeUndefined() @@ -183,7 +183,7 @@ describe('verifyRelay', () => { expect(result.actualAffiliateFeeAmountCryptoBaseUnit).toBe('999') }) - it('returns unverified when relayTransactionMetadata.relayId is missing', async () => { + it('returns FAILED when relayTransactionMetadata.relayId is missing', async () => { const swapWithoutMetadata = { ...swap, metadata: {} as SwapperSpecificMetadata } as Swap service = new SwapVerificationService(makeHttpMock(relayResponse)) @@ -191,22 +191,22 @@ describe('verifyRelay', () => { const result = await service.verifySwap(swapWithoutMetadata) expect(result).toMatchObject({ - isVerified: false, + verificationStatus: 'FAILED', hasAffiliate: false, - error: 'Missing relayId in relayTransactionMetadata', + noAffiliateReason: 'Missing relayId in relayTransactionMetadata', }) }) - it('returns unverified when API returns no requests', async () => { + it('returns PENDING when API returns no requests (transient — retry next tick)', async () => { service = new SwapVerificationService(makeHttpMock({ requests: [] })) const result = await service.verifySwap(swap) - expect(result.isVerified).toBe(false) - expect(result.error).toBe('No request data found from Relay API') + expect(result.verificationStatus).toBe('PENDING') + expect(result.noAffiliateReason).toBe('No request data found from Relay API') }) - it('returns unverified when the HTTP call fails', async () => { + 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 @@ -215,7 +215,7 @@ describe('verifyRelay', () => { const result = await service.verifySwap(swap) - expect(result.isVerified).toBe(false) - expect(result.error).toBe('upstream 500') + expect(result.verificationStatus).toBe('PENDING') + expect(result.noAffiliateReason).toBe('upstream 500') }) }) diff --git a/apps/swap-service/src/verification/swap-verification.service.ts b/apps/swap-service/src/verification/swap-verification.service.ts index 83508e2..4602fde 100644 --- a/apps/swap-service/src/verification/swap-verification.service.ts +++ b/apps/swap-service/src/verification/swap-verification.service.ts @@ -26,7 +26,7 @@ import { ZrxApiResponse, ZrxTrade, } from './types' -import { applyBps, logVerification, THORCHAIN_PRECISION, thorchainToNativePrecision } from './utils' +import { applyBps, noAffiliateResult, THORCHAIN_PRECISION, thorchainToNativePrecision } from './utils' @Injectable() export class SwapVerificationService { @@ -59,73 +59,75 @@ export class SwapVerificationService { } async verifySwap(swap: Swap): Promise { - const { swapId, swapperName } = swap - - const unverified = (error: string): SwapVerificationResult => ({ - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error, - }) - - try { - this.logger.log(`Verifying affiliate for swap ${swapId} on ${swapperName}`) - - switch (swapperName) { - case SwapperName.NearIntents: - return await this.verifyNearIntents(swap) - case SwapperName.Relay: - return await this.verifyRelay(swap) - case SwapperName.CowSwap: - return await this.verifyCowSwap(swap) - case SwapperName.Portals: - return await this.verifyPortals(swap) - case SwapperName.Thorchain: - return await this.verifyThorchain(swap) - case SwapperName.Mayachain: - return await this.verifyMaya(swap) - case SwapperName.Chainflip: - return await this.verifyChainflip(swap) - case SwapperName.Zrx: - return await this.verifyZrx(swap) - case SwapperName.Bebop: - return await this.verifyBebop(swap) - case SwapperName.ArbitrumBridge: - return await this.verifyArbitrumBridge(swap) - case SwapperName.ButterSwap: - return await this.verifyButterSwap(swap) - case SwapperName.Cetus: - return await this.verifyCetus(swap) - case SwapperName.Sunio: - return await this.verifySunio(swap) - case SwapperName.Avnu: - return await this.verifyAvnu(swap) - case SwapperName.Stonfi: - return await this.verifyStonfi(swap) - case SwapperName.Across: - return await this.verifyAcross(swap) - case SwapperName.Debridge: - case SwapperName.Test: - return unverified(`Verification not implemented for ${swapperName}`) - default: { - const _exhaustive: never = swapperName - void _exhaustive - throw new Error('unreachable') + const result = await (async () => { + try { + switch (swap.swapperName) { + case SwapperName.NearIntents: + return await this.verifyNearIntents(swap) + case SwapperName.Relay: + return await this.verifyRelay(swap) + case SwapperName.CowSwap: + return await this.verifyCowSwap(swap) + case SwapperName.Portals: + return await this.verifyPortals(swap) + case SwapperName.Thorchain: + return await this.verifyThorchain(swap) + case SwapperName.Mayachain: + return await this.verifyMaya(swap) + case SwapperName.Chainflip: + return await this.verifyChainflip(swap) + case SwapperName.Zrx: + return await this.verifyZrx(swap) + case SwapperName.Bebop: + return await this.verifyBebop(swap) + case SwapperName.ArbitrumBridge: + return await this.verifyArbitrumBridge(swap) + case SwapperName.ButterSwap: + return await this.verifyButterSwap(swap) + case SwapperName.Cetus: + return await this.verifyCetus(swap) + case SwapperName.Sunio: + return await this.verifySunio(swap) + case SwapperName.Avnu: + return await this.verifyAvnu(swap) + case SwapperName.Stonfi: + return await this.verifyStonfi(swap) + case SwapperName.Across: + return await this.verifyAcross(swap) + case SwapperName.Debridge: + case SwapperName.Test: + return noAffiliateResult('SUCCESS', `Verification not implemented`) + default: { + const _exhaustive: never = swap.swapperName + void _exhaustive + throw new Error('unreachable') + } } + } catch (error) { + return noAffiliateResult('PENDING', error instanceof Error ? error.message : 'Unknown error') } - } catch (error) { - this.logger.error(`Error verifying swap ${swapId} for ${swapperName}:`, error) + })() - return unverified(error instanceof Error ? error.message : 'Unknown error') - } + this.logResult(swap, result) + + return result + } + + private logResult(swap: Swap, result: SwapVerificationResult): void { + const affiliateDetails = result.hasAffiliate + ? `${result.affiliateAddress} - ${result.affiliateBps} bps` + : `noAffiliateReason=${result.noAffiliateReason ?? 'unknown'}` + + this.logger.log( + `${swap.swapperName} verification for swap: ${swap.swapId} ${result.verificationStatus} (${affiliateDetails})`, + ) } private async verifyNearIntents(swap: Swap): Promise { - const { swapId, metadata } = swap + const { metadata } = swap const depositAddress = metadata.nearIntentsSpecific?.depositAddress - if (!depositAddress) throw new Error('Missing depositAddress in nearIntentsSpecific metadata') + if (!depositAddress) return noAffiliateResult('FAILED', 'Missing depositAddress in nearIntentsSpecific metadata') const status = await OneClickService.getExecutionStatus(depositAddress) @@ -142,8 +144,8 @@ export class SwapVerificationService { const verifiedSellAmountCryptoBaseUnit = swapDetails.depositedAmount || swapDetails.amountIn || quote.amountIn - const result: SwapVerificationResult = { - isVerified: true, + return { + verificationStatus: 'SUCCESS', hasAffiliate: Boolean(shapeshiftFee), affiliateBps: shapeshiftFee?.fee, affiliateAddress: shapeshiftFee?.recipient, @@ -152,24 +154,20 @@ export class SwapVerificationService { // 1Click applies the appFee bps to the input amount, so realized fee = input × bps / 10000. actualAffiliateFeeAmountCryptoBaseUnit: applyBps(verifiedSellAmountCryptoBaseUnit, shapeshiftFee?.fee), } - - logVerification(this.logger, SwapperName.NearIntents, swapId, result, { status: status.status }) - - return result } private async verifyRelay(swap: Swap): Promise { - const { swapId, metadata } = swap + const { metadata } = swap const relayId = metadata.relayTransactionMetadata?.relayId - if (!relayId) throw new Error('Missing relayId in relayTransactionMetadata') + if (!relayId) return noAffiliateResult('FAILED', 'Missing relayId in relayTransactionMetadata') const { data } = await firstValueFrom( this.httpService.get(`${env.VITE_RELAY_API_URL}/requests/v2?id=${relayId}`), ) const request = data?.requests?.[0] - if (!request?.data) throw new Error('No request data found from Relay API') + if (!request?.data) return noAffiliateResult('PENDING', 'No request data found from Relay API') const appFees = request.data.appFees?.length ? request.data.appFees : (request.data.paidAppFees ?? []) @@ -211,8 +209,8 @@ export class SwapVerificationService { return priceUsd?.toString() })() - const result: SwapVerificationResult = { - isVerified: true, + return { + verificationStatus: 'SUCCESS', hasAffiliate: Boolean(shapeshiftFee), affiliateBps, affiliateAddress: shapeshiftFee?.recipient, @@ -222,13 +220,6 @@ export class SwapVerificationService { actualAffiliateFeeAssetId, actualAffiliateFeeUsd: actualAffiliateFeeUsd ?? undefined, } - - logVerification(this.logger, SwapperName.Relay, swapId, result, { - status: request.status, - feeAsset: result.actualAffiliateFeeAssetId, - }) - - return result } private async verifyCowSwap(swap: Swap): Promise { @@ -242,79 +233,53 @@ export class SwapVerificationService { const appDataHash = (metadata?.cowswapQuoteSpecific as { quote?: { appDataHash?: string } } | undefined)?.quote ?.appDataHash - if (!appDataHash) { - this.logger.warn(`CowSwap - Missing appDataHash for swap ${swapId}`) - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: 'Missing appDataHash in metadata', - } - } + if (!appDataHash) return noAffiliateResult('FAILED', 'Missing appDataHash in metadata') - try { - // ALWAYS fetch appData from CowSwap API to verify it's legitimate - this.logger.log(`CowSwap - Fetching appData from API using hash ${appDataHash} for swap ${swapId}`) - const cowNetwork = assertGetCowNetwork(sellChainId) - const response = await firstValueFrom( - this.httpService.get( - `${this.cowswapApiUrl}/${cowNetwork}/api/v1/app_data/${appDataHash}`, - ), - ) - - const decodedAppData = JSON.parse(response.data.fullAppData) as CowSwapDecodedAppData - - // Check if appCode is "shapeshift" - const appCode = decodedAppData?.appCode - const hasShapeshiftAppCode = appCode?.toLowerCase() === this.shapeshiftCowswapAppCode.toLowerCase() - - // Extract partner fee information from metadata.partnerFee - const partnerFee = decodedAppData?.metadata?.partnerFee - const affiliateBps = partnerFee?.bps - const affiliateAddress = partnerFee?.recipient - - // We have ShapeShift affiliate if appCode is shapeshift AND we have partnerFee - const hasShapeshiftAffiliate = hasShapeshiftAppCode && !!partnerFee - - let verifiedSellAmountCryptoBaseUnit: string | undefined - const orderUid = txHash || (metadata?.cowswapOrderUid as string | undefined) - if (orderUid) { - try { - const orderResponse = await firstValueFrom( - this.httpService.get(`${this.cowswapApiUrl}/${cowNetwork}/api/v1/orders/${orderUid}`), - ) - verifiedSellAmountCryptoBaseUnit = - orderResponse.data?.executedSellAmountBeforeFees?.toString() ?? - orderResponse.data?.executedSellAmount?.toString() - } catch (orderErr) { - this.logger.warn(`CowSwap - Failed to fetch order ${orderUid} for amount verification:`, orderErr) - } - } + // ALWAYS fetch appData from CowSwap API to verify it's legitimate + this.logger.log(`CowSwap - Fetching appData from API using hash ${appDataHash} for swap ${swapId}`) + const cowNetwork = assertGetCowNetwork(sellChainId) + const response = await firstValueFrom( + this.httpService.get( + `${this.cowswapApiUrl}/${cowNetwork}/api/v1/app_data/${appDataHash}`, + ), + ) - const result: SwapVerificationResult = { - isVerified: true, - hasAffiliate: hasShapeshiftAffiliate, - affiliateBps: hasShapeshiftAffiliate && affiliateBps ? affiliateBps : undefined, - affiliateAddress: hasShapeshiftAffiliate ? affiliateAddress : undefined, - verifiedSellAmountCryptoBaseUnit, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - } + const decodedAppData = JSON.parse(response.data.fullAppData) as CowSwapDecodedAppData + + const appCode = decodedAppData?.appCode + const hasShapeshiftAppCode = appCode?.toLowerCase() === this.shapeshiftCowswapAppCode.toLowerCase() - logVerification(this.logger, SwapperName.CowSwap, swapId, result) - - return result - } catch (error) { - this.logger.error(`Error verifying CowSwap for swap ${swapId}:`, error) - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: error instanceof Error ? error.message : 'Failed to decode CowSwap appData', + const partnerFee = decodedAppData?.metadata?.partnerFee + const affiliateBps = partnerFee?.bps + const affiliateAddress = partnerFee?.recipient + + const hasShapeshiftAffiliate = hasShapeshiftAppCode && !!partnerFee + + // Order-amount fetch is best-effort — failure here shouldn't fail the whole verification. + let verifiedSellAmountCryptoBaseUnit: string | undefined + const orderUid = txHash || (metadata?.cowswapOrderUid as string | undefined) + if (orderUid) { + try { + const orderResponse = await firstValueFrom( + this.httpService.get(`${this.cowswapApiUrl}/${cowNetwork}/api/v1/orders/${orderUid}`), + ) + verifiedSellAmountCryptoBaseUnit = + orderResponse.data?.executedSellAmountBeforeFees?.toString() ?? + orderResponse.data?.executedSellAmount?.toString() + } catch (orderErr) { + this.logger.warn(`CowSwap - Failed to fetch order ${orderUid} for amount verification:`, orderErr) } } + + return { + verificationStatus: 'SUCCESS', + hasAffiliate: hasShapeshiftAffiliate, + affiliateBps: hasShapeshiftAffiliate && affiliateBps ? affiliateBps : undefined, + affiliateAddress: hasShapeshiftAffiliate ? affiliateAddress : undefined, + verifiedSellAmountCryptoBaseUnit, + actualBuyAmountCryptoBaseUnit: undefined, + actualAffiliateFeeAmountCryptoBaseUnit: undefined, + } } private async verifyPortals(swap: Swap): Promise { @@ -327,852 +292,475 @@ export class SwapVerificationService { // Get the orderId from the swap (stored as the quote id) const orderId = (metadata?.portalsTransactionMetadata as { orderId?: string } | undefined)?.orderId - - if (!orderId) { - this.logger.warn(`Portals - Missing orderId for swap ${swapId}`) - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: 'Missing orderId in metadata', - } - } + if (!orderId) return noAffiliateResult('FAILED', 'Missing orderId in metadata') // Get the expected treasury address for this chain let expectedTreasuryAddress: string try { expectedTreasuryAddress = getTreasuryAddressFromChainId(sellChainId) } catch { - this.logger.warn(`Portals - Unsupported chain for treasury address: ${sellChainId}`) - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: `Unsupported chain for treasury address: ${sellChainId}`, - } + return noAffiliateResult('FAILED', `Unsupported chain for treasury address: ${sellChainId}`) } - try { - // ALWAYS fetch order status from Portals API to verify it's legitimate - this.logger.log(`Portals - Fetching order status from API using orderId ${orderId} for swap ${swapId}`) - const response = await firstValueFrom( - this.httpService.get(`${this.portalsApiUrl}/v2/portal/status?orderId=${orderId}`), - ) - - const orderData = response.data - this.logger.log(`Portals - Fetched and verified order from API for swap ${swapId}`) - - // Get partner from the API response context - const partner = orderData?.context?.partner - - if (!partner) { - this.logger.warn(`Portals - No partner found in API response for swap ${swapId}`) - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: 'No partner found in Portals API response', - } - } + // ALWAYS fetch order status from Portals API to verify it's legitimate + this.logger.log(`Portals - Fetching order status from API using orderId ${orderId} for swap ${swapId}`) + const response = await firstValueFrom( + this.httpService.get(`${this.portalsApiUrl}/v2/portal/status?orderId=${orderId}`), + ) - // Verify partner matches the expected treasury address (case-insensitive for EVM addresses) - const hasShapeshiftAffiliate = partner.toLowerCase() === expectedTreasuryAddress.toLowerCase() + const orderData = response.data + this.logger.log(`Portals - Fetched and verified order from API for swap ${swapId}`) - const feeAmount = orderData?.context?.feeAmount + const partner = orderData?.context?.partner + if (!partner) return noAffiliateResult('SUCCESS', 'No partner found in Portals API response') - const verifiedSellAmountCryptoBaseUnit = orderData?.context?.inputAmount?.toString() ?? undefined + const hasShapeshiftAffiliate = partner.toLowerCase() === expectedTreasuryAddress.toLowerCase() - const result: SwapVerificationResult = { - isVerified: true, - hasAffiliate: hasShapeshiftAffiliate, - affiliateBps: swap.affiliateBps ?? undefined, - affiliateAddress: hasShapeshiftAffiliate ? expectedTreasuryAddress : undefined, - verifiedSellAmountCryptoBaseUnit, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - } + const verifiedSellAmountCryptoBaseUnit = orderData?.context?.inputAmount?.toString() ?? undefined - logVerification(this.logger, SwapperName.Portals, swapId, result, { feeAmount }) - - return result - } catch (error) { - this.logger.error(`Error verifying Portals for swap ${swapId}:`, error) - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: error instanceof Error ? error.message : 'Failed to verify Portals order', - } + return { + verificationStatus: 'SUCCESS', + hasAffiliate: hasShapeshiftAffiliate, + affiliateBps: swap.affiliateBps ?? undefined, + affiliateAddress: hasShapeshiftAffiliate ? expectedTreasuryAddress : undefined, + verifiedSellAmountCryptoBaseUnit, + actualBuyAmountCryptoBaseUnit: undefined, + actualAffiliateFeeAmountCryptoBaseUnit: orderData?.context?.feeAmount, } } private async verifyThorchain(swap: Swap): Promise { - const { swapId } = swap const txHash = swap.sellTxHash || undefined - if (!txHash) { - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: 'Missing txHash for Thorchain verification', - } - } - - try { - // SECURITY: Query Thorchain node API to verify memo contains affiliate info - const txUrl = `${this.thorchainNodeUrl}/thorchain/tx/${txHash}` + if (!txHash) return noAffiliateResult('FAILED', 'Missing txHash for Thorchain verification') - this.logger.log(`Thorchain - Fetching tx from node API: ${txUrl}`) + // SECURITY: Query Thorchain node API to verify memo contains affiliate info + const txUrl = `${this.thorchainNodeUrl}/thorchain/tx/${txHash}` - const response = await firstValueFrom(this.httpService.get(txUrl)) + this.logger.log(`Thorchain - Fetching tx from node API: ${txUrl}`) - const observedTx = response.data?.observed_tx + const response = await firstValueFrom(this.httpService.get(txUrl)) - if (!observedTx || !observedTx.tx) { - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: 'No observed transaction found', - } - } + const observedTx = response.data?.observed_tx - const memo: string | undefined = observedTx.tx.memo - if (!memo) { - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: 'No memo found in transaction', - } - } + if (!observedTx || !observedTx.tx) return noAffiliateResult('PENDING', 'No observed transaction found') - // Parse memo format: =:r:thor1dz68dtlzrxnjflha9vvs7yt7p77mqdnf5yugww:131082237:ss:0 - // The affiliate code is after the 4th colon, followed by fee in bps - const memoPattern = new RegExp(`:${this.shapeshiftThorchainAffiliate}:(\\d+)`, 'i') - const memoMatch = memo.match(memoPattern) + const memo: string | undefined = observedTx.tx.memo + // Observed tx's memo is immutable on chain — absence is definitive, not transient. + if (!memo) return noAffiliateResult('SUCCESS', 'No memo found in transaction') - const hasShapeshiftAffiliate = !!memoMatch - const affiliateBps = memoMatch ? parseInt(memoMatch[1]) : undefined + // Parse memo format: =:r:thor1dz68dtlzrxnjflha9vvs7yt7p77mqdnf5yugww:131082237:ss:0 + // The affiliate code is after the 4th colon, followed by fee in bps + const memoPattern = new RegExp(`:${this.shapeshiftThorchainAffiliate}:(\\d+)`, 'i') + const memoMatch = memo.match(memoPattern) - const coins = observedTx.tx.coins - const sellAssetPrecision = swap.sellAsset.precision ?? THORCHAIN_PRECISION - const firstCoinAmount = coins?.[0]?.amount - const verifiedSellAmountCryptoBaseUnit = firstCoinAmount - ? thorchainToNativePrecision(firstCoinAmount, sellAssetPrecision) - : undefined + const hasShapeshiftAffiliate = !!memoMatch + const affiliateBps = memoMatch ? parseInt(memoMatch[1]) : undefined - const result: SwapVerificationResult = { - isVerified: true, - hasAffiliate: hasShapeshiftAffiliate, - affiliateBps: hasShapeshiftAffiliate && affiliateBps ? affiliateBps : undefined, - affiliateAddress: hasShapeshiftAffiliate ? this.shapeshiftThorchainAffiliate : undefined, - verifiedSellAmountCryptoBaseUnit, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - } + const coins = observedTx.tx.coins + const sellAssetPrecision = swap.sellAsset.precision ?? THORCHAIN_PRECISION + const firstCoinAmount = coins?.[0]?.amount + const verifiedSellAmountCryptoBaseUnit = firstCoinAmount + ? thorchainToNativePrecision(firstCoinAmount, sellAssetPrecision) + : undefined - logVerification(this.logger, SwapperName.Thorchain, swapId, result, { memo }) - - return result - } catch (error) { - this.logger.error(`Error verifying Thorchain for swap ${swapId}:`, error) - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: error instanceof Error ? error.message : 'Failed to fetch Thorchain data from node', - } + return { + verificationStatus: 'SUCCESS', + hasAffiliate: hasShapeshiftAffiliate, + affiliateBps: hasShapeshiftAffiliate && affiliateBps ? affiliateBps : undefined, + affiliateAddress: hasShapeshiftAffiliate ? this.shapeshiftThorchainAffiliate : undefined, + verifiedSellAmountCryptoBaseUnit, + actualBuyAmountCryptoBaseUnit: undefined, + actualAffiliateFeeAmountCryptoBaseUnit: undefined, } } private async verifyMaya(swap: Swap): Promise { - const { swapId } = swap const txHash = swap.sellTxHash || undefined - if (!txHash) { - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: 'Missing txHash for Maya verification', - } - } - - try { - // SECURITY: Query Maya node API to verify memo contains affiliate info - const txUrl = `${this.mayachainNodeUrl}/mayachain/tx/${txHash}` + if (!txHash) return noAffiliateResult('FAILED', 'Missing txHash for Maya verification') - this.logger.log(`Maya - Fetching tx from node API: ${txUrl}`) + // SECURITY: Query Maya node API to verify memo contains affiliate info + const txUrl = `${this.mayachainNodeUrl}/mayachain/tx/${txHash}` - const response = await firstValueFrom(this.httpService.get(txUrl)) + this.logger.log(`Maya - Fetching tx from node API: ${txUrl}`) - const observedTx = response.data?.observed_tx + const response = await firstValueFrom(this.httpService.get(txUrl)) - if (!observedTx || !observedTx.tx) { - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: 'No observed transaction found', - } - } + const observedTx = response.data?.observed_tx - const memo: string | undefined = observedTx.tx.memo - if (!memo) { - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: 'No memo found in transaction', - } - } + if (!observedTx || !observedTx.tx) return noAffiliateResult('PENDING', 'No observed transaction found') - // Parse memo format: =:r:maya1dz68dtlzrxnjflha9vvs7yt7p77mqdnf5yugww:131082237:ss:0 - // The affiliate code is after the 4th colon, followed by fee in bps - const memoPattern = new RegExp(`:${this.shapeshiftMayaAffiliate}:(\\d+)`, 'i') - const memoMatch = memo.match(memoPattern) + const memo: string | undefined = observedTx.tx.memo + // Observed tx's memo is immutable on chain — absence is definitive, not transient. + if (!memo) return noAffiliateResult('SUCCESS', 'No memo found in transaction') - const hasShapeshiftAffiliate = !!memoMatch - const affiliateBps = memoMatch ? parseInt(memoMatch[1]) : undefined + // Parse memo format: =:r:maya1dz68dtlzrxnjflha9vvs7yt7p77mqdnf5yugww:131082237:ss:0 + // The affiliate code is after the 4th colon, followed by fee in bps + const memoPattern = new RegExp(`:${this.shapeshiftMayaAffiliate}:(\\d+)`, 'i') + const memoMatch = memo.match(memoPattern) - const coins = observedTx.tx.coins - const sellAssetPrecision = swap.sellAsset.precision ?? THORCHAIN_PRECISION - const firstCoinAmount = coins?.[0]?.amount - const verifiedSellAmountCryptoBaseUnit = firstCoinAmount - ? thorchainToNativePrecision(firstCoinAmount, sellAssetPrecision) - : undefined + const hasShapeshiftAffiliate = !!memoMatch + const affiliateBps = memoMatch ? parseInt(memoMatch[1]) : undefined - const result: SwapVerificationResult = { - isVerified: true, - hasAffiliate: hasShapeshiftAffiliate, - affiliateBps: hasShapeshiftAffiliate && affiliateBps ? affiliateBps : undefined, - affiliateAddress: hasShapeshiftAffiliate ? this.shapeshiftMayaAffiliate : undefined, - verifiedSellAmountCryptoBaseUnit, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - } + const coins = observedTx.tx.coins + const sellAssetPrecision = swap.sellAsset.precision ?? THORCHAIN_PRECISION + const firstCoinAmount = coins?.[0]?.amount + const verifiedSellAmountCryptoBaseUnit = firstCoinAmount + ? thorchainToNativePrecision(firstCoinAmount, sellAssetPrecision) + : undefined - logVerification(this.logger, SwapperName.Mayachain, swapId, result, { memo }) - - return result - } catch (error) { - this.logger.error(`Error verifying Maya for swap ${swapId}:`, error) - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: error instanceof Error ? error.message : 'Failed to fetch Maya data from node', - } + return { + verificationStatus: 'SUCCESS', + hasAffiliate: hasShapeshiftAffiliate, + affiliateBps: hasShapeshiftAffiliate && affiliateBps ? affiliateBps : undefined, + affiliateAddress: hasShapeshiftAffiliate ? this.shapeshiftMayaAffiliate : undefined, + verifiedSellAmountCryptoBaseUnit, + actualBuyAmountCryptoBaseUnit: undefined, + actualAffiliateFeeAmountCryptoBaseUnit: undefined, } } private async verifyChainflip(swap: Swap): Promise { - const { swapId } = swap const metadata = swap.metadata as Record const chainflipSwapId = metadata?.chainflipSwapId as string | undefined - if (!chainflipSwapId) { - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: 'Missing chainflipSwapId in metadata', - } + if (!chainflipSwapId) return noAffiliateResult('FAILED', 'Missing chainflipSwapId in metadata') + + const statusUrl = `${this.chainflipApiUrl}/swaps/${chainflipSwapId}` + + const headers: Record = {} + if (this.chainflipApiKey) { + headers['Authorization'] = `Bearer ${this.chainflipApiKey}` } - try { - const statusUrl = `${this.chainflipApiUrl}/swaps/${chainflipSwapId}` + const response = await firstValueFrom(this.httpService.get(statusUrl, { headers })) - const headers: Record = {} - if (this.chainflipApiKey) { - headers['Authorization'] = `Bearer ${this.chainflipApiKey}` - } + const swapData = response.data - const response = await firstValueFrom(this.httpService.get(statusUrl, { headers })) + if (!swapData) return noAffiliateResult('PENDING', 'No swap data found from Chainflip API') - const swapData = response.data + const affiliate = swapData.affiliate || swapData.affiliateName + const affiliateBps = swapData.affiliateBps || swapData.affiliateFee - if (!swapData) { - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: 'No swap data found from Chainflip API', - } - } + const hasShapeshiftAffiliate = affiliate?.toLowerCase() === this.shapeshiftChainflipAffiliate.toLowerCase() - // Check for affiliate information in the swap data - const affiliate = swapData.affiliate || swapData.affiliateName - const affiliateBps = swapData.affiliateBps || swapData.affiliateFee - - const hasShapeshiftAffiliate = affiliate?.toLowerCase() === this.shapeshiftChainflipAffiliate.toLowerCase() - - const verifiedSellAmountCryptoBaseUnit = ( - swapData.depositAmount ?? - swapData.ingressAmount ?? - swapData.sourceAmount - )?.toString() - - return { - isVerified: true, - hasAffiliate: hasShapeshiftAffiliate, - affiliateBps: hasShapeshiftAffiliate && affiliateBps ? parseInt(String(affiliateBps)) : undefined, - affiliateAddress: hasShapeshiftAffiliate ? this.shapeshiftChainflipAffiliate : undefined, - verifiedSellAmountCryptoBaseUnit, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - } - } catch (error) { - this.logger.error(`Error verifying Chainflip for swap ${swapId}:`, error) - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: error instanceof Error ? error.message : 'Failed to fetch Chainflip swap data', - } + const verifiedSellAmountCryptoBaseUnit = ( + swapData.depositAmount ?? + swapData.ingressAmount ?? + swapData.sourceAmount + )?.toString() + + return { + verificationStatus: 'SUCCESS', + hasAffiliate: hasShapeshiftAffiliate, + affiliateBps: hasShapeshiftAffiliate && affiliateBps ? parseInt(String(affiliateBps)) : undefined, + affiliateAddress: hasShapeshiftAffiliate ? this.shapeshiftChainflipAffiliate : undefined, + verifiedSellAmountCryptoBaseUnit, + actualBuyAmountCryptoBaseUnit: undefined, + actualAffiliateFeeAmountCryptoBaseUnit: undefined, } } private async verifyZrx(swap: Swap): Promise { - const { swapId } = swap const txHash = swap.sellTxHash || undefined const metadata = swap.metadata as Record const tradeHash = txHash || (metadata?.tradeHash as string | undefined) || (metadata?.txHash as string | undefined) - if (!tradeHash) { - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: 'Missing tradeHash in metadata', - } - } + if (!tradeHash) return noAffiliateResult('FAILED', 'Missing tradeHash in metadata') - try { - // Use 0x Trade Analytics API via ShapeShift proxy to verify the trade - const requestUrl = `${this.zrxApiUrl}/trade-analytics/swap?txHash=${tradeHash}` - - const response = await firstValueFrom(this.httpService.get(requestUrl)) - - const trades: ZrxTrade[] = Array.isArray(response.data) - ? response.data - : response.data?.trades || response.data?.results || [] - - const trade = trades.find( - (t: ZrxTrade) => - t.txHash?.toLowerCase() === tradeHash.toLowerCase() || - t.transactionHash?.toLowerCase() === tradeHash.toLowerCase(), - ) - - if (!trade) { - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: `Trade not found in 0x analytics (searched ${trades.length} trades)`, - } - } + // Use 0x Trade Analytics API via ShapeShift proxy to verify the trade + const requestUrl = `${this.zrxApiUrl}/trade-analytics/swap?txHash=${tradeHash}` - // Check for ShapeShift's partner/integrator name - // The field could be integratorId, integratorName, or affiliateName - const integratorId = trade.integratorId || trade.integratorName || trade.affiliateName - const hasShapeshiftAffiliate = integratorId?.toLowerCase() === this.shapeshift0xIntegrator.toLowerCase() + const response = await firstValueFrom(this.httpService.get(requestUrl)) - // Extract fee information - // The fee could be in integratorFee, affiliateFee, or partnerFee fields - // Note: 0x fees are typically in decimal format (e.g., 0.0015 for 15 bps) - const integratorFee = trade.integratorFee || trade.affiliateFee || trade.partnerFee - let affiliateBps: number | undefined + const trades: ZrxTrade[] = Array.isArray(response.data) + ? response.data + : response.data?.trades || response.data?.results || [] - if (integratorFee) { - // Convert decimal fee to basis points (e.g., 0.0015 -> 15 bps) - affiliateBps = parseFloat(integratorFee) * 10000 - } + const trade = trades.find( + (t: ZrxTrade) => + t.txHash?.toLowerCase() === tradeHash.toLowerCase() || + t.transactionHash?.toLowerCase() === tradeHash.toLowerCase(), + ) - const verifiedSellAmountCryptoBaseUnit = (trade.sellAmount ?? trade.inputTokenAmount ?? trade.amount)?.toString() + if (!trade) + return noAffiliateResult('PENDING', `Trade not found in 0x analytics (searched ${trades.length} trades)`) - return { - isVerified: true, - hasAffiliate: hasShapeshiftAffiliate, - affiliateBps, - affiliateAddress: hasShapeshiftAffiliate ? this.shapeshift0xIntegrator : undefined, - verifiedSellAmountCryptoBaseUnit, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - } - } catch (error) { - this.logger.error(`Error verifying 0x for swap ${swapId}:`, error) - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: error instanceof Error ? error.message : 'Failed to verify 0x trade', - } + // The integrator field could be integratorId, integratorName, or affiliateName + const integratorId = trade.integratorId || trade.integratorName || trade.affiliateName + const hasShapeshiftAffiliate = integratorId?.toLowerCase() === this.shapeshift0xIntegrator.toLowerCase() + + // 0x fees are decimal (e.g., 0.0015 == 15 bps) + const integratorFee = trade.integratorFee || trade.affiliateFee || trade.partnerFee + const affiliateBps = integratorFee ? Math.round(parseFloat(integratorFee) * 10000) : undefined + + const verifiedSellAmountCryptoBaseUnit = (trade.sellAmount ?? trade.inputTokenAmount ?? trade.amount)?.toString() + + return { + verificationStatus: 'SUCCESS', + hasAffiliate: hasShapeshiftAffiliate, + affiliateBps, + affiliateAddress: hasShapeshiftAffiliate ? this.shapeshift0xIntegrator : undefined, + verifiedSellAmountCryptoBaseUnit, + actualBuyAmountCryptoBaseUnit: undefined, + actualAffiliateFeeAmountCryptoBaseUnit: undefined, } } private async verifyBebop(swap: Swap): Promise { - const { swapId } = swap const txHash = swap.sellTxHash || undefined - if (!txHash) { - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: 'Missing txHash for Bebop verification', - } + if (!txHash) return noAffiliateResult('FAILED', 'Missing txHash for Bebop verification') + + // Query trade history with source filter, scoped to swap createdAt +/- 1 hour (nanoseconds) + const swapTimestamp = swap.createdAt.getTime() + const oneHour = 60 * 60 * 1000 + const startNano = (swapTimestamp - oneHour) * 1_000_000 + const endNano = (swapTimestamp + oneHour) * 1_000_000 + + const queryParams = new URLSearchParams({ + start: startNano.toString(), + end: endNano.toString(), + source: this.shapeshiftBebopSource, + }) + + const headers = { + 'source-auth': this.bebopApiKey, } - try { - // Get swap timestamp to create time range (swap createdAt +/- 1 hour) - const swapTimestamp = swap.createdAt.getTime() - const oneHour = 60 * 60 * 1000 - const startNano = (swapTimestamp - oneHour) * 1_000_000 // Convert to nanoseconds - const endNano = (swapTimestamp + oneHour) * 1_000_000 - - // Query trade history with source filter and time range - const queryParams = new URLSearchParams({ + const requestUrl = `${this.bebopApiUrl}/history/v2/trades?${queryParams.toString()}` + + this.logger.log(`Bebop API Request - URL: ${requestUrl}`) + this.logger.log( + `Bebop API Request - Params: ${JSON.stringify({ start: startNano.toString(), end: endNano.toString(), source: this.shapeshiftBebopSource, - }) + swapTimestamp: new Date(swapTimestamp).toISOString(), + })}`, + ) + this.logger.log(`Bebop API Request - Looking for txHash: ${txHash}`) - const headers = { - 'source-auth': this.bebopApiKey, - } + const response = await firstValueFrom(this.httpService.get(requestUrl, { headers })) - const requestUrl = `${this.bebopApiUrl}/history/v2/trades?${queryParams.toString()}` - - // Log request details - this.logger.log(`Bebop API Request - URL: ${requestUrl}`) - this.logger.log( - `Bebop API Request - Params: ${JSON.stringify({ - start: startNano.toString(), - end: endNano.toString(), - source: this.shapeshiftBebopSource, - swapTimestamp: new Date(swapTimestamp).toISOString(), - })}`, - ) - this.logger.log(`Bebop API Request - Headers: { 'source-auth': '[REDACTED]' }`) - this.logger.log(`Bebop API Request - Looking for txHash: ${txHash}`) - - const response = await firstValueFrom(this.httpService.get(requestUrl, { headers })) - - this.logger.log(`Bebop API Response - Status: ${response.status}`) - this.logger.log(`Bebop API Response - Data: ${JSON.stringify(response.data)}`) - - const trades = response.data?.results || [] - this.logger.log(`Bebop API Response - Found ${trades.length} trades`) - - const trade = trades.find((t: BebopTrade) => t.txHash?.toLowerCase() === txHash.toLowerCase()) - - if (!trade) { - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: 'Trade not found in Bebop history', - } - } + this.logger.log(`Bebop API Response - Status: ${response.status}`) + this.logger.log(`Bebop API Response - Data: ${JSON.stringify(response.data)}`) - // Since we filtered by source=shapeshift, finding the trade means it was made through ShapeShift - const hasShapeshiftAffiliate = true + const trades = response.data?.results || [] + this.logger.log(`Bebop API Response - Found ${trades.length} trades`) - // Extract partner fee from the response (partnerFeeBps is in basis points) - const partnerFeeBps = trade.partnerFeeBps - const affiliateBps = partnerFeeBps != null ? Number(partnerFeeBps) : undefined + const trade = trades.find((t: BebopTrade) => t.txHash?.toLowerCase() === txHash.toLowerCase()) - const sellTokenEntries = trade.sellTokens ? Object.values(trade.sellTokens) : [] - const verifiedSellAmountCryptoBaseUnit = sellTokenEntries[0]?.amount?.toString() ?? undefined + if (!trade) return noAffiliateResult('PENDING', 'Trade not found in Bebop history') - const result: SwapVerificationResult = { - isVerified: true, - hasAffiliate: hasShapeshiftAffiliate, - affiliateBps, - affiliateAddress: this.shapeshiftBebopSource, - verifiedSellAmountCryptoBaseUnit, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - } + // Filtered by source=shapeshift, so finding the trade implies a ShapeShift route. + const hasShapeshiftAffiliate = true - logVerification(this.logger, SwapperName.Bebop, swapId, result) - - return result - } catch (error) { - this.logger.error(`Error verifying Bebop for swap ${swapId}:`, error) - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: error instanceof Error ? error.message : 'Failed to verify Bebop trade', - } - } - } + const partnerFeeBps = trade.partnerFeeBps + const affiliateBps = partnerFeeBps != null ? Number(partnerFeeBps) : undefined - private verifyArbitrumBridge(swap: Swap): Promise { - const { swapId } = swap + const sellTokenEntries = trade.sellTokens ? Object.values(trade.sellTokens) : [] + const verifiedSellAmountCryptoBaseUnit = sellTokenEntries[0]?.amount?.toString() ?? undefined - const result: SwapVerificationResult = { - isVerified: true, - hasAffiliate: false, + return { + verificationStatus: 'SUCCESS', + hasAffiliate: hasShapeshiftAffiliate, + affiliateBps, + affiliateAddress: this.shapeshiftBebopSource, + verifiedSellAmountCryptoBaseUnit, actualBuyAmountCryptoBaseUnit: undefined, actualAffiliateFeeAmountCryptoBaseUnit: undefined, } + } - logVerification(this.logger, SwapperName.ArbitrumBridge, swapId, result) - - return Promise.resolve(result) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private verifyArbitrumBridge(_swap: Swap): Promise { + return Promise.resolve({ + verificationStatus: 'SUCCESS', + hasAffiliate: false, + actualBuyAmountCryptoBaseUnit: undefined, + actualAffiliateFeeAmountCryptoBaseUnit: undefined, + }) } private async verifyButterSwap(swap: Swap): Promise { - const { swapId } = swap const txHash = swap.sellTxHash || undefined const metadata = swap.metadata as Record - if (!txHash) { - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: 'Missing txHash for ButterSwap verification', - } - } - - try { - const apiUrl = `https://bs-app-api.chainservice.io/api/queryBridgeInfoBySourceHash?hash=${txHash}` + if (!txHash) return noAffiliateResult('FAILED', 'Missing txHash for ButterSwap verification') - this.logger.log(`ButterSwap - Fetching bridge info from API: ${apiUrl}`) + const apiUrl = `https://bs-app-api.chainservice.io/api/queryBridgeInfoBySourceHash?hash=${txHash}` - const response = await firstValueFrom(this.httpService.get(apiUrl)) + this.logger.log(`ButterSwap - Fetching bridge info from API: ${apiUrl}`) - const bridgeInfo = response.data?.data?.info + const response = await firstValueFrom(this.httpService.get(apiUrl)) - if (!bridgeInfo) { - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: 'No bridge info found', - } - } + const bridgeInfo = response.data?.data?.info + if (!bridgeInfo) return noAffiliateResult('PENDING', 'No bridge info found') - const entrance = bridgeInfo.entrance - const hasShapeshiftAffiliate = entrance?.toLowerCase() === this.shapeshiftButterswapEntrance.toLowerCase() + const entrance = bridgeInfo.entrance + const hasShapeshiftAffiliate = entrance?.toLowerCase() === this.shapeshiftButterswapEntrance.toLowerCase() - const affiliateBps = swap.affiliateBps ?? undefined + const affiliateBps = swap.affiliateBps ?? undefined - const verifiedSellAmountCryptoBaseUnit = ( - (metadata?.sellAmountIncludingProtocolFeesCryptoBaseUnit as string | undefined) ?? swap.sellAmountCryptoBaseUnit - )?.toString() + const verifiedSellAmountCryptoBaseUnit = ( + (metadata?.sellAmountIncludingProtocolFeesCryptoBaseUnit as string | undefined) ?? swap.sellAmountCryptoBaseUnit + )?.toString() - const result: SwapVerificationResult = { - isVerified: true, - hasAffiliate: hasShapeshiftAffiliate, - affiliateBps: hasShapeshiftAffiliate && affiliateBps ? affiliateBps : undefined, - affiliateAddress: hasShapeshiftAffiliate ? this.shapeshiftButterswapEntrance : undefined, - verifiedSellAmountCryptoBaseUnit, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - } - - logVerification(this.logger, SwapperName.ButterSwap, swapId, result) - - return result - } catch (error) { - this.logger.error(`Error verifying ButterSwap for swap ${swapId}:`, error) - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: error instanceof Error ? error.message : 'Failed to verify ButterSwap trade', - } + return { + verificationStatus: 'SUCCESS', + hasAffiliate: hasShapeshiftAffiliate, + affiliateBps: hasShapeshiftAffiliate && affiliateBps ? affiliateBps : undefined, + affiliateAddress: hasShapeshiftAffiliate ? this.shapeshiftButterswapEntrance : undefined, + verifiedSellAmountCryptoBaseUnit, + actualBuyAmountCryptoBaseUnit: undefined, + actualAffiliateFeeAmountCryptoBaseUnit: undefined, } } - private verifyCetus(swap: Swap): Promise { - const { swapId } = swap + // eslint-disable-next-line @typescript-eslint/require-await + private async verifyCetus(swap: Swap): Promise { const txHash = swap.sellTxHash || undefined const metadata = swap.metadata as Record - if (!txHash) { - return Promise.resolve({ - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: 'Missing txHash for Cetus verification', - }) - } + if (!txHash) return noAffiliateResult('FAILED', 'Missing txHash for Cetus verification') - try { - // TODO: Implement on-chain/API verification for Cetus - const affiliateBps = swap.affiliateBps ?? undefined - const hasAffiliate = affiliateBps !== undefined && affiliateBps > 0 - - const verifiedSellAmountCryptoBaseUnit = ( - (metadata?.sellAmountIncludingProtocolFeesCryptoBaseUnit as string | undefined) ?? swap.sellAmountCryptoBaseUnit - )?.toString() - - const result: SwapVerificationResult = { - isVerified: false, - hasAffiliate, - affiliateBps: hasAffiliate ? affiliateBps : undefined, - verifiedSellAmountCryptoBaseUnit, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - } + // TODO: Implement on-chain/API verification for Cetus + const affiliateBps = swap.affiliateBps ?? undefined + const hasAffiliate = affiliateBps !== undefined && affiliateBps > 0 - logVerification(this.logger, SwapperName.Cetus, swapId, result) - - return Promise.resolve(result) - } catch (error) { - this.logger.error(`Error verifying Cetus for swap ${swapId}:`, error) - return Promise.resolve({ - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: error instanceof Error ? error.message : 'Failed to verify Cetus trade', - }) + const verifiedSellAmountCryptoBaseUnit = ( + (metadata?.sellAmountIncludingProtocolFeesCryptoBaseUnit as string | undefined) ?? swap.sellAmountCryptoBaseUnit + )?.toString() + + return { + verificationStatus: 'SUCCESS', + hasAffiliate, + affiliateBps: hasAffiliate ? affiliateBps : undefined, + verifiedSellAmountCryptoBaseUnit, + actualBuyAmountCryptoBaseUnit: undefined, + actualAffiliateFeeAmountCryptoBaseUnit: undefined, } } - private verifySunio(swap: Swap): Promise { - const { swapId } = swap + // eslint-disable-next-line @typescript-eslint/require-await + private async verifySunio(swap: Swap): Promise { const txHash = swap.sellTxHash || undefined const metadata = swap.metadata as Record - if (!txHash) { - return Promise.resolve({ - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: 'Missing txHash for Sun.io verification', - }) - } + if (!txHash) return noAffiliateResult('FAILED', 'Missing txHash for Sun.io verification') - try { - // TODO: Implement on-chain/API verification for Sun.io - const affiliateBps = swap.affiliateBps ?? undefined - const hasAffiliate = affiliateBps !== undefined && affiliateBps > 0 - - const verifiedSellAmountCryptoBaseUnit = ( - (metadata?.sellAmountIncludingProtocolFeesCryptoBaseUnit as string | undefined) ?? swap.sellAmountCryptoBaseUnit - )?.toString() - - const result: SwapVerificationResult = { - isVerified: false, - hasAffiliate, - affiliateBps: hasAffiliate ? affiliateBps : undefined, - verifiedSellAmountCryptoBaseUnit, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - } + // TODO: Implement on-chain/API verification for Sun.io + const affiliateBps = swap.affiliateBps ?? undefined + const hasAffiliate = affiliateBps !== undefined && affiliateBps > 0 + + const verifiedSellAmountCryptoBaseUnit = ( + (metadata?.sellAmountIncludingProtocolFeesCryptoBaseUnit as string | undefined) ?? swap.sellAmountCryptoBaseUnit + )?.toString() - logVerification(this.logger, SwapperName.Sunio, swapId, result) - - return Promise.resolve(result) - } catch (error) { - this.logger.error(`Error verifying Sun.io for swap ${swapId}:`, error) - return Promise.resolve({ - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: error instanceof Error ? error.message : 'Failed to verify Sun.io trade', - }) + return { + verificationStatus: 'SUCCESS', + hasAffiliate, + affiliateBps: hasAffiliate ? affiliateBps : undefined, + verifiedSellAmountCryptoBaseUnit, + actualBuyAmountCryptoBaseUnit: undefined, + actualAffiliateFeeAmountCryptoBaseUnit: undefined, } } - private verifyAvnu(swap: Swap): Promise { - const { swapId } = swap + // eslint-disable-next-line @typescript-eslint/require-await + private async verifyAvnu(swap: Swap): Promise { const txHash = swap.sellTxHash || undefined const metadata = swap.metadata as Record - if (!txHash) { - return Promise.resolve({ - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: 'Missing txHash for AVNU verification', - }) - } + if (!txHash) return noAffiliateResult('FAILED', 'Missing txHash for AVNU verification') - try { - // TODO: Implement on-chain/API verification for AVNU - const affiliateBps = swap.affiliateBps ?? undefined - const hasAffiliate = affiliateBps !== undefined && affiliateBps > 0 - const affiliateAddress = swap.affiliateAddress ?? undefined - - const verifiedSellAmountCryptoBaseUnit = ( - (metadata?.sellAmountIncludingProtocolFeesCryptoBaseUnit as string | undefined) ?? swap.sellAmountCryptoBaseUnit - )?.toString() - - const result: SwapVerificationResult = { - isVerified: false, - hasAffiliate, - affiliateBps: hasAffiliate ? affiliateBps : undefined, - affiliateAddress, - verifiedSellAmountCryptoBaseUnit, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - } + // TODO: Implement on-chain/API verification for AVNU + const affiliateBps = swap.affiliateBps ?? undefined + const hasAffiliate = affiliateBps !== undefined && affiliateBps > 0 + const affiliateAddress = swap.affiliateAddress ?? undefined + + const verifiedSellAmountCryptoBaseUnit = ( + (metadata?.sellAmountIncludingProtocolFeesCryptoBaseUnit as string | undefined) ?? swap.sellAmountCryptoBaseUnit + )?.toString() - logVerification(this.logger, SwapperName.Avnu, swapId, result) - - return Promise.resolve(result) - } catch (error) { - this.logger.error(`Error verifying AVNU for swap ${swapId}:`, error) - return Promise.resolve({ - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: error instanceof Error ? error.message : 'Failed to verify AVNU trade', - }) + return { + verificationStatus: 'SUCCESS', + hasAffiliate, + affiliateBps: hasAffiliate ? affiliateBps : undefined, + affiliateAddress, + verifiedSellAmountCryptoBaseUnit, + actualBuyAmountCryptoBaseUnit: undefined, + actualAffiliateFeeAmountCryptoBaseUnit: undefined, } } - private verifyStonfi(swap: Swap): Promise { - const { swapId } = swap + // eslint-disable-next-line @typescript-eslint/require-await + private async verifyStonfi(swap: Swap): Promise { const txHash = swap.sellTxHash || undefined const metadata = swap.metadata as Record - if (!txHash) { - return Promise.resolve({ - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: 'Missing txHash for STON.fi verification', - }) - } - - try { - // TODO: Implement on-chain/API verification for STON.fi - const stonfiSpecific = metadata?.stonfiSpecific as StonfiQuoteMetadata | undefined + if (!txHash) return noAffiliateResult('FAILED', 'Missing txHash for STON.fi verification') - const referrerAddress = stonfiSpecific?.referrerAddress + // TODO: Implement on-chain/API verification for STON.fi + const stonfiSpecific = metadata?.stonfiSpecific as StonfiQuoteMetadata | undefined - const affiliateBps = swap.affiliateBps ?? stonfiSpecific?.referrerFeeBps ?? undefined + const referrerAddress = stonfiSpecific?.referrerAddress - const hasAffiliate = !!referrerAddress && (affiliateBps !== undefined ? affiliateBps > 0 : false) + const affiliateBps = swap.affiliateBps ?? stonfiSpecific?.referrerFeeBps ?? undefined - const verifiedSellAmountCryptoBaseUnit = ( - (metadata?.sellAmountIncludingProtocolFeesCryptoBaseUnit as string | undefined) ?? swap.sellAmountCryptoBaseUnit - )?.toString() + const hasAffiliate = !!referrerAddress && (affiliateBps !== undefined ? affiliateBps > 0 : false) - const result: SwapVerificationResult = { - isVerified: false, - hasAffiliate, - affiliateBps: hasAffiliate ? affiliateBps : undefined, - affiliateAddress: referrerAddress, - verifiedSellAmountCryptoBaseUnit, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - } + const verifiedSellAmountCryptoBaseUnit = ( + (metadata?.sellAmountIncludingProtocolFeesCryptoBaseUnit as string | undefined) ?? swap.sellAmountCryptoBaseUnit + )?.toString() - logVerification(this.logger, SwapperName.Stonfi, swapId, result) - - return Promise.resolve(result) - } catch (error) { - this.logger.error(`Error verifying STON.fi for swap ${swapId}:`, error) - return Promise.resolve({ - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: error instanceof Error ? error.message : 'Failed to verify STON.fi trade', - }) + return { + verificationStatus: 'SUCCESS', + hasAffiliate, + affiliateBps: hasAffiliate ? affiliateBps : undefined, + affiliateAddress: referrerAddress, + verifiedSellAmountCryptoBaseUnit, + actualBuyAmountCryptoBaseUnit: undefined, + actualAffiliateFeeAmountCryptoBaseUnit: undefined, } } private async verifyAcross(swap: Swap): Promise { - const { swapId } = swap const txHash = swap.sellTxHash || undefined const metadata = swap.metadata as Record - if (!txHash) { - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: 'Missing txHash for Across verification', - } - } + if (!txHash) return noAffiliateResult('FAILED', 'Missing txHash for Across verification') - try { - const statusUrl = `${this.acrossApiUrl}/deposit/status?depositTxnRef=${txHash}` + const statusUrl = `${this.acrossApiUrl}/deposit/status?depositTxnRef=${txHash}` - this.logger.log(`Across - Fetching deposit status from API: ${statusUrl}`) + this.logger.log(`Across - Fetching deposit status from API: ${statusUrl}`) - const response = await firstValueFrom(this.httpService.get(statusUrl)) + const response = await firstValueFrom(this.httpService.get(statusUrl)) - // TODO: Implement on-chain/API verification for Across - const depositStatus = response.data + // TODO: Implement on-chain/API verification for Across + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const depositStatus = response.data - const affiliateBps = swap.affiliateBps ?? undefined - const hasAffiliate = affiliateBps !== undefined && affiliateBps > 0 + const affiliateBps = swap.affiliateBps ?? undefined + const hasAffiliate = affiliateBps !== undefined && affiliateBps > 0 - const affiliateAddress = - (metadata?.appFeeRecipient as string | undefined) || (metadata?.integratorId as string | undefined) + const affiliateAddress = + (metadata?.appFeeRecipient as string | undefined) || (metadata?.integratorId as string | undefined) - const verifiedSellAmountCryptoBaseUnit = ( - (metadata?.sellAmountIncludingProtocolFeesCryptoBaseUnit as string | undefined) ?? swap.sellAmountCryptoBaseUnit - )?.toString() + const verifiedSellAmountCryptoBaseUnit = ( + (metadata?.sellAmountIncludingProtocolFeesCryptoBaseUnit as string | undefined) ?? swap.sellAmountCryptoBaseUnit + )?.toString() - const result: SwapVerificationResult = { - isVerified: false, - hasAffiliate, - affiliateBps: hasAffiliate ? affiliateBps : undefined, - affiliateAddress, - verifiedSellAmountCryptoBaseUnit, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - } - - logVerification(this.logger, SwapperName.Across, swapId, result, { status: depositStatus?.status }) - - return result - } catch (error) { - this.logger.error(`Error verifying Across for swap ${swapId}:`, error) - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: error instanceof Error ? error.message : 'Failed to verify Across deposit', - } + return { + verificationStatus: 'SUCCESS', + hasAffiliate, + affiliateBps: hasAffiliate ? affiliateBps : undefined, + affiliateAddress, + verifiedSellAmountCryptoBaseUnit, + actualBuyAmountCryptoBaseUnit: undefined, + actualAffiliateFeeAmountCryptoBaseUnit: undefined, } } } diff --git a/apps/swap-service/src/verification/utils.ts b/apps/swap-service/src/verification/utils.ts index b74d92d..e1f0e91 100644 --- a/apps/swap-service/src/verification/utils.ts +++ b/apps/swap-service/src/verification/utils.ts @@ -1,8 +1,6 @@ -import type { Logger } from '@nestjs/common' - import type { SwapVerificationResult } from '@shapeshift/shared-types' -import type { SwapperName } from '@shapeshiftoss/swapper' +export const BPS_DENOMINATOR = 10000n export const THORCHAIN_PRECISION = 8 export const thorchainToNativePrecision = (thorchainAmount: string, nativePrecision: number): string => { @@ -13,7 +11,16 @@ export const thorchainToNativePrecision = (thorchainAmount: string, nativePrecis return trimmed || '0' } -const BPS_DENOMINATOR = 10000n +export const noAffiliateResult = ( + verificationStatus: SwapVerificationResult['verificationStatus'], + noAffiliateReason: string, +): SwapVerificationResult => ({ + verificationStatus, + hasAffiliate: false, + actualBuyAmountCryptoBaseUnit: undefined, + actualAffiliateFeeAmountCryptoBaseUnit: undefined, + noAffiliateReason, +}) export const applyBps = (amount: string | undefined, bps: number | undefined): string | undefined => { if (!amount || bps === undefined) return undefined @@ -24,31 +31,3 @@ export const applyBps = (amount: string | undefined, bps: number | undefined): s return undefined } } - -export const logVerification = ( - logger: Logger, - swapperName: SwapperName, - swapId: string, - result: SwapVerificationResult, - extra?: Record, -): void => { - const affiliate = result.hasAffiliate - ? `affiliate=${result.affiliateAddress} (${result.affiliateBps} bps)` - : 'affiliate=none' - - const extras = Object.entries(extra ?? {}) - .filter(([, v]) => v !== undefined && v !== null && v !== '') - .map(([k, v]) => `${k}=${v}`) - - const segments = [ - `${swapperName} verification`, - `swapId=${swapId}`, - affiliate, - `sell=${result.verifiedSellAmountCryptoBaseUnit ?? 'none'}`, - `buy=${result.actualBuyAmountCryptoBaseUnit ?? 'none'}`, - `fee=${result.actualAffiliateFeeAmountCryptoBaseUnit ?? 'none'}`, - ...extras, - ] - - logger.log(segments.join(' | ')) -} diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index eed9f9b..325eeea 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -114,7 +114,7 @@ export interface SwapStatusResponse { } export interface SwapVerificationResult { - isVerified: boolean + verificationStatus: 'PENDING' | 'SUCCESS' | 'FAILED' hasAffiliate: boolean affiliateBps?: number affiliateAddress?: string @@ -123,7 +123,7 @@ export interface SwapVerificationResult { actualAffiliateFeeAmountCryptoBaseUnit: string | undefined actualAffiliateFeeAssetId?: string actualAffiliateFeeUsd?: string - error?: string + noAffiliateReason?: string } export interface Fees { diff --git a/prisma/migrations/20260505235634_add_swap_verification_status/migration.sql b/prisma/migrations/20260505235634_add_swap_verification_status/migration.sql new file mode 100644 index 0000000..a9568ae --- /dev/null +++ b/prisma/migrations/20260505235634_add_swap_verification_status/migration.sql @@ -0,0 +1,8 @@ +-- CreateEnum +CREATE TYPE "public"."VerificationStatus" AS ENUM ('PENDING', 'SUCCESS', 'FAILED'); + +-- AlterTable +ALTER TABLE "public"."swaps" ADD COLUMN "verificationStatus" "public"."VerificationStatus" NOT NULL DEFAULT 'PENDING'; + +-- CreateIndex +CREATE INDEX "swaps_verificationStatus_status_idx" ON "public"."swaps"("verificationStatus", "status"); diff --git a/prisma/schema/swap-service.prisma b/prisma/schema/swap-service.prisma index a7db75e..b6f655d 100644 --- a/prisma/schema/swap-service.prisma +++ b/prisma/schema/swap-service.prisma @@ -14,13 +14,13 @@ model Affiliate { } model Swap { - swapId String @id + swapId String @id sellAsset Json buyAsset Json sellAmountCryptoBaseUnit String expectedBuyAmountCryptoBaseUnit String actualBuyAmountCryptoBaseUnit String? - status String @default("PENDING") + status String @default("PENDING") source String swapperName String sellAccountId String @@ -30,9 +30,9 @@ model Swap { buyTxHash String? txLink String? statusMessage String? - isStreaming Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + isStreaming Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt metadata Json userId String referralCode String? @@ -41,16 +41,24 @@ model Swap { affiliateAssetUsd String? isAffiliateVerified Boolean? affiliateVerificationDetails Json? - affiliateAddress String? @db.Citext + affiliateAddress String? @db.Citext affiliateBps Int? origin String? affiliateFeeAssetId String? actualAffiliateFeeAmountCryptoBaseUnit String? shapeshiftBps Int + verificationStatus VerificationStatus @default(PENDING) @@index([referralCode]) @@index([affiliateAddress]) @@index([status, sellTxHash]) + @@index([verificationStatus, status]) @@index([userId]) @@map("swaps") } + +enum VerificationStatus { + PENDING + SUCCESS + FAILED +}