diff --git a/apps/swap-service/.env.example b/apps/swap-service/.env.example index 7913f77..2ce1173 100644 --- a/apps/swap-service/.env.example +++ b/apps/swap-service/.env.example @@ -9,7 +9,6 @@ VITE_CHAINFLIP_API_KEY= VITE_NEAR_INTENTS_API_KEY= VITE_RELAY_API_KEY= VITE_ACROSS_API_KEY= -VITE_BOB_GATEWAY_API_KEY= # Database Configuration DATABASE_URL="postgresql://postgres:password@db:5432/microservices" diff --git a/apps/swap-service/src/env.ts b/apps/swap-service/src/env.ts index 087cad2..09965e6 100644 --- a/apps/swap-service/src/env.ts +++ b/apps/swap-service/src/env.ts @@ -19,7 +19,6 @@ const schema = z.object({ VITE_CHAINFLIP_API_KEY: z.string().min(1), VITE_RELAY_API_KEY: z.string().min(1), VITE_ACROSS_API_KEY: z.string().min(1), - VITE_BOB_GATEWAY_API_KEY: z.string().length(32), // Database DATABASE_URL: z.string().min(1), diff --git a/apps/swap-service/src/polling/swap-polling.service.ts b/apps/swap-service/src/polling/swap-polling.service.ts index 10ac88e..e2e7f82 100644 --- a/apps/swap-service/src/polling/swap-polling.service.ts +++ b/apps/swap-service/src/polling/swap-polling.service.ts @@ -7,6 +7,8 @@ import { WebsocketGateway } from '../websocket/websocket.gateway' const POLL_CONCURRENCY = 10 +const swapIds = (swaps: Swap[]): string => swaps.map((swap) => swap.swapId).join(', ') + @Injectable() export class SwapPollingService { private readonly logger = new Logger(SwapPollingService.name) @@ -28,7 +30,7 @@ export class SwapPollingService { const swaps = await this.swapsService.getPendingTxSwaps() if (swaps.length === 0) return - this.logger.log(`Polling tx status for ${swaps.length} swaps`) + this.logger.log(`Polling tx status for ${swaps.length} swaps (${swapIds(swaps)})`) await this.runWorkers(swaps, (swap) => this.pollTxStatus(swap)) } catch (err) { this.logger.error('Failed to poll pending tx status:', err) @@ -46,7 +48,7 @@ export class SwapPollingService { const swaps = await this.swapsService.getPendingVerificationSwaps() if (swaps.length === 0) return - this.logger.log(`Polling verification for ${swaps.length} swaps`) + this.logger.log(`Polling verification for ${swaps.length} swaps (${swapIds(swaps)})`) await this.runWorkers(swaps, (swap) => this.pollVerification(swap)) } catch (err) { this.logger.error('Failed to poll pending verification:', err) diff --git a/apps/swap-service/src/swaps/__tests__/utils.test.ts b/apps/swap-service/src/swaps/__tests__/utils.test.ts index 9d0d7c9..d29c58e 100644 --- a/apps/swap-service/src/swaps/__tests__/utils.test.ts +++ b/apps/swap-service/src/swaps/__tests__/utils.test.ts @@ -3,7 +3,7 @@ import { Logger } from '@nestjs/common' import { mayachainAssetId } from '@shapeshiftoss/caip' import type { Swap } from '../types' -import { calculateFeeForSwap } from '../utils' +import { calculateFeeForSwap, describeError } from '../utils' // Minimal swap shape exercising calculateFeeForSwap's fee/volume math. CACAO fee asset so a stored // '0' fee amount resolves to actualFeeUsd = 0 (the real 0-bps case this branch introduced). @@ -80,3 +80,56 @@ describe('calculateFeeForSwap volume reconstruction', () => { expect(result?.volumeUsd).toBe(100) }) }) + +describe('describeError', () => { + // Shaped like a real AxiosError: isAxiosError is the flag axios.isAxiosError checks. + const axiosError = (status: number | undefined, data?: unknown, message = 'Request failed with status code 500') => + Object.assign(new Error(message), { + isAxiosError: true, + response: status === undefined ? undefined : { status, data }, + }) + + it('prefers the message the server sent over the generic axios message', () => { + expect(describeError(axiosError(404, { message: 'tx not found' }))).toBe('tx not found') + }) + + it('reads an error key when the body has no message', () => { + expect(describeError(axiosError(429, { error: 'rate limited' }))).toBe('rate limited') + }) + + it('serialises a message or error that is not a string', () => { + expect(describeError(axiosError(400, { error: { message: 'nested' } }))).toBe('{"message":"nested"}') + }) + + it('falls back when the body has neither key', () => { + const reason = describeError( + axiosError(400, { errors: { amount: ['too small'] } }, 'Request failed with status code 400'), + ) + + expect(reason).toBe('Request failed with status code 400') + }) + + it('ignores a string body so an error page never reaches the log', () => { + const reason = describeError(axiosError(403, ''.padEnd(5000, 'x'), 'Request failed with status code 403')) + + expect(reason).toBe('Request failed with status code 403') + }) + + it('falls back to the message for an axios error that never got a response', () => { + expect(describeError(axiosError(undefined, undefined, 'connect ETIMEDOUT'))).toBe('connect ETIMEDOUT') + }) + + it('returns the message for a plain error', () => { + expect(describeError(new Error('Non-JSON response: HTTP 403 Forbidden'))).toBe( + 'Non-JSON response: HTTP 403 Forbidden', + ) + }) + + it('handles a thrown string', () => { + expect(describeError('boom')).toBe('boom') + }) + + it('does not stringify a thrown object as [object Object]', () => { + expect(describeError({ nope: true })).toBe('Unknown error') + }) +}) diff --git a/apps/swap-service/src/swaps/swapper-config.ts b/apps/swap-service/src/swaps/swapper-config.ts index ebafdc2..5389bb7 100644 --- a/apps/swap-service/src/swaps/swapper-config.ts +++ b/apps/swap-service/src/swaps/swapper-config.ts @@ -17,7 +17,7 @@ export const getSwapperConfig = (): SwapperConfig => ({ VITE_ACROSS_API_URL: env.VITE_ACROSS_API_URL, VITE_ACROSS_INTEGRATOR_ID: '', VITE_BEBOP_API_KEY: env.VITE_BEBOP_API_KEY, - VITE_BOB_GATEWAY_API_KEY: env.VITE_BOB_GATEWAY_API_KEY, + VITE_BOB_GATEWAY_API_KEY: '', VITE_CHAINFLIP_API_KEY: env.VITE_CHAINFLIP_API_KEY, VITE_CHAINFLIP_API_URL: env.VITE_CHAINFLIP_API_URL, VITE_COWSWAP_BASE_URL: env.VITE_COWSWAP_BASE_URL, diff --git a/apps/swap-service/src/swaps/swaps.service.ts b/apps/swap-service/src/swaps/swaps.service.ts index ff08b24..66c7f2a 100644 --- a/apps/swap-service/src/swaps/swaps.service.ts +++ b/apps/swap-service/src/swaps/swaps.service.ts @@ -34,6 +34,7 @@ import { buildStatusNotification, calculateFeeForSwap, computeSellAmountUsd, + describeError, fetchUsdPrices, toSwap, toSwapperSwap, @@ -360,8 +361,6 @@ export class SwapsService { } 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}`) @@ -393,10 +392,13 @@ export class SwapsService { statusMessage: typeof statusMessage === 'string' ? statusMessage : '', } } catch (error) { - logger.error(`Failed to check swap status for ${swapId}:`, error) + const reason = describeError(error) + + logger.error(`Failed to check swap status for ${swapId}: ${reason}`) + return { status: 'PENDING', - statusMessage: `Error polling status: ${error instanceof Error ? error.message : 'Unknown error'}`, + statusMessage: `Error polling status: ${reason}`, } } } diff --git a/apps/swap-service/src/swaps/utils.ts b/apps/swap-service/src/swaps/utils.ts index 8f00e04..5bed442 100644 --- a/apps/swap-service/src/swaps/utils.ts +++ b/apps/swap-service/src/swaps/utils.ts @@ -1,5 +1,6 @@ import { Logger } from '@nestjs/common' import type { Swap as PrismaSwap } from '@prisma/client' +import axios from 'axios' import type { CreateSwapDto } from '@shapeshift/shared-types' import { baseUnitToPrecision } from '@shapeshift/shared-utils' @@ -49,6 +50,25 @@ export const toSwapperSwap = (swap: Swap): SwapperSwap => updatedAt: swap.updatedAt.getTime(), }) as unknown as SwapperSwap +const responseDetail = (data: unknown): string | undefined => { + if (!data || typeof data !== 'object') return + + const { message, error } = data as { message?: unknown; error?: unknown } + const detail = message ?? error + + if (detail === undefined || detail === null) return + + return typeof detail === 'string' ? detail : JSON.stringify(detail) +} + +export const describeError = (error: unknown): string => { + if (axios.isAxiosError(error)) return responseDetail(error.response?.data) ?? error.message + if (error instanceof Error) return error.message + if (typeof error === 'string') return error + + return 'Unknown error' +} + // formatAmount up to 8 decimals, no trailing zeros export const formatAmount = (amount: string | number): string => { return bnOrZero(amount)