Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion apps/swap-service/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 0 additions & 1 deletion apps/swap-service/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
6 changes: 4 additions & 2 deletions apps/swap-service/src/polling/swap-polling.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
55 changes: 54 additions & 1 deletion apps/swap-service/src/swaps/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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, '<html>'.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')
})
})
2 changes: 1 addition & 1 deletion apps/swap-service/src/swaps/swapper-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 6 additions & 4 deletions apps/swap-service/src/swaps/swaps.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
buildStatusNotification,
calculateFeeForSwap,
computeSellAmountUsd,
describeError,
fetchUsdPrices,
toSwap,
toSwapperSwap,
Expand Down Expand Up @@ -360,8 +361,6 @@ export class SwapsService {
}

async checkSwapStatus(swapId: string): Promise<SwapStatusResponse> {
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}`)

Expand Down Expand Up @@ -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}`,
}
}
}
Expand Down
20 changes: 20 additions & 0 deletions apps/swap-service/src/swaps/utils.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
Expand Down
Loading