Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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
7 changes: 6 additions & 1 deletion packages/trading/src/consts.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { EcdsaSigningScheme, SigningScheme } from '@cowprotocol/sdk-order-book'
import { mapSupportedNetworks, SupportedChainId } from '@cowprotocol/sdk-config'
import { CowEnv, mapSupportedNetworks, SupportedChainId } from '@cowprotocol/sdk-config'

export const BFF_ENDPOINTS: Record<CowEnv, string> = {
prod: 'https://bff.cow.fi',
staging: 'https://bff.barn.cow.fi',
}

export const DEFAULT_QUOTE_VALIDITY = 60 * 30 // 30 min

Expand Down
214 changes: 214 additions & 0 deletions packages/trading/src/cowBffClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { CoWBFFClient } from './cowBffClient'

// Mock fetch globally
const mockFetch = jest.fn()
global.fetch = mockFetch

describe('CoWBFFClient', () => {
beforeEach(() => {
mockFetch.mockClear()
})

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

describe('constructor', () => {
it('should use prod base URL when env is prod', () => {
const client = new CoWBFFClient('prod')
expect((client as any).baseUrl).toBe('https://bff.cow.fi')
})

it('should use barn base URL when env is staging', () => {
const client = new CoWBFFClient('staging')
expect((client as any).baseUrl).toBe('https://bff.barn.cow.fi')
})

it('should default to prod when no env provided', () => {
const client = new CoWBFFClient()
expect((client as any).baseUrl).toBe('https://bff.cow.fi')
})
})

describe('getSlippageTolerance', () => {
const sellToken = '0x6b175474e89094c44da98b954eedeac495271d0f'
const buyToken = '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599'
const chainId = 1

it('should return slippage tolerance on successful API response', async () => {
const client = new CoWBFFClient('prod')
const mockResponse = { slippageBps: 150 }
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
})

const result = await client.getSlippageTolerance({ sellToken, buyToken, chainId })

expect(mockFetch).toHaveBeenCalledWith(
`https://bff.cow.fi/${chainId}/markets/${sellToken}-${buyToken}/slippageTolerance`,
{
method: 'GET',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
signal: expect.any(AbortSignal),
}
)
expect(result).toEqual({ slippageBps: 150 })
})

it('should use staging URL when env is staging', async () => {
const client = new CoWBFFClient('staging')
const mockResponse = { slippageBps: 200 }
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
})

await client.getSlippageTolerance({ sellToken, buyToken, chainId })

expect(mockFetch).toHaveBeenCalledWith(
`https://bff.barn.cow.fi/${chainId}/markets/${sellToken}-${buyToken}/slippageTolerance`,
expect.any(Object)
)
})

it('should return null on HTTP error response', async () => {
const client = new CoWBFFClient()
mockFetch.mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
})

const result = await client.getSlippageTolerance({ sellToken, buyToken, chainId })

expect(result).toBeNull()
})

it('should return null on invalid response structure', async () => {
const client = new CoWBFFClient()
const mockResponse = { invalidField: 'value' }
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
})

const result = await client.getSlippageTolerance({ sellToken, buyToken, chainId })

expect(result).toBeNull()
})

it('should return null when slippageBps is not a number', async () => {
const client = new CoWBFFClient()
const mockResponse = { slippageBps: 'invalid' }
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
})

const result = await client.getSlippageTolerance({ sellToken, buyToken, chainId })

expect(result).toBeNull()
})

it('should return null on network error', async () => {
const client = new CoWBFFClient()
mockFetch.mockRejectedValue(new Error('Network error'))

const result = await client.getSlippageTolerance({ sellToken, buyToken, chainId })

expect(result).toBeNull()
})

it('should return null when response is null', async () => {
const client = new CoWBFFClient()
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(null),
})

const result = await client.getSlippageTolerance({ sellToken, buyToken, chainId })

expect(result).toBeNull()
})

it('should construct correct URL for different chain IDs', async () => {
const client = new CoWBFFClient()
const mockResponse = { slippageBps: 100 }
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
})

await client.getSlippageTolerance({ sellToken, buyToken, chainId: 137 })

expect(mockFetch).toHaveBeenCalledWith(
`https://bff.cow.fi/137/markets/${sellToken}-${buyToken}/slippageTolerance`,
expect.any(Object)
)
})

it('should apply timeout to fetch request', async () => {
const client = new CoWBFFClient()

// Mock fetch to simulate timeout by throwing AbortError
mockFetch.mockRejectedValue(new Error('The operation was aborted'))

const customTimeoutMs = 100
const result = await client.getSlippageTolerance({
sellToken,
buyToken,
chainId,
timeoutMs: customTimeoutMs,
})

expect(result).toBeNull()
})

it('should use default timeout of 2000ms when not specified', async () => {
const client = new CoWBFFClient()
const mockResponse = { slippageBps: 150 }
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
})

await client.getSlippageTolerance({ sellToken, buyToken, chainId })

// Verify the call was made with signal (indicates timeout setup)
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
signal: expect.any(AbortSignal),
})
)
})

it('should use custom timeout when specified', async () => {
const client = new CoWBFFClient()
const mockResponse = { slippageBps: 150 }
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
})

await client.getSlippageTolerance({
sellToken,
buyToken,
chainId,
timeoutMs: 5000,
})

// Verify the call was made with signal (indicates timeout setup)
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
signal: expect.any(AbortSignal),
})
)
})
})
})
78 changes: 78 additions & 0 deletions packages/trading/src/cowBffClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { log } from '@cowprotocol/sdk-common'
import { SupportedChainId, CowEnv } from '@cowprotocol/sdk-config'
import { BFF_ENDPOINTS } from './consts'

const DEFAULT_TIMEOUT = 2000 // 2 sec

export interface SlippageToleranceResponse {
slippageBps: number
}

export interface SlippageToleranceRequest {
chainId: SupportedChainId
sellToken: string
buyToken: string
timeoutMs?: number
}

export class CoWBFFClient {
private readonly baseUrl: string

constructor(env: CowEnv = 'prod') {
this.baseUrl = BFF_ENDPOINTS[env]
}

/**
* Fetches slippage tolerance from the API for a given token pair
* @param sellToken - Address of the token to sell (e.g., '0x6b175474e89094c44da98b954eedeac495271d0f')
* @param buyToken - Address of the token to buy (e.g., '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599')
* @param chainId - Chain ID for the API request
* @param timeoutMs - Request timeout in milliseconds (default: 2000ms)
* @returns Promise<SlippageToleranceResponse | null> - Returns null if API call fails
*/
async getSlippageTolerance({
sellToken,
buyToken,
chainId,
timeoutMs = DEFAULT_TIMEOUT,
}: SlippageToleranceRequest): Promise<SlippageToleranceResponse | null> {
try {
const url = `${this.baseUrl}/${chainId}/markets/${sellToken}-${buyToken}/slippageTolerance`

log(`Fetching slippage tolerance from API: ${url} (timeout: ${timeoutMs}ms)`)

const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)

const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
signal: controller.signal,
})

clearTimeout(timeoutId)

if (!response.ok) {
log(`Slippage tolerance API error: ${response.status} ${response.statusText}`)
return null
}

const data = await response.json()

// Validate response structure
if (typeof data !== 'object' || data === null || typeof data.slippageBps !== 'number') {
log('Invalid slippage tolerance API response structure')
return null
}

log(`Retrieved slippage tolerance from API: ${data.slippageBps} BPS`)
return { slippageBps: data.slippageBps }
} catch (error) {
log(`Failed to fetch slippage tolerance from API: ${error instanceof Error ? error.message : 'Unknown error'}`)
return null
}
}
}
Loading
Loading