Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
3 changes: 3 additions & 0 deletions packages/bridging/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ export type {
NearIntentsQuoteResult,
NearIntentsBridgeProviderOptions,
} from './providers/near-intents/NearIntentsBridgeProvider'

export { RelayBridgeProvider } from './providers/relay/RelayBridgeProvider'
export type { RelayQuoteResult, RelayBridgeProviderOptions } from './providers/relay/RelayBridgeProvider'
75 changes: 75 additions & 0 deletions packages/bridging/src/providers/relay/RelayApi.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { RelayApi } from './RelayApi'

describe('RelayApi: Shape of API response', () => {
let api: RelayApi

beforeEach(() => {
api = new RelayApi()
})

it('getCurrencies returns tokens for Base', async () => {
const result = await api.getCurrencies({ chainIds: [8453], depositAddressOnly: true })

expect(result).toBeDefined()
expect(Array.isArray(result)).toBe(true)
expect(result.length).toBeGreaterThan(0)

const currency = result[0]
expect(currency).toHaveProperty('chainId', 8453)
expect(currency).toHaveProperty('address')
expect(currency).toHaveProperty('symbol')
expect(currency).toHaveProperty('name')
expect(currency).toHaveProperty('decimals')
})

it('getCurrencies returns tokens for Ethereum mainnet', async () => {
const result = await api.getCurrencies({ chainIds: [1], depositAddressOnly: true })

expect(result).toBeDefined()
expect(Array.isArray(result)).toBe(true)
expect(result.length).toBeGreaterThan(0)
})

it('getQuote returns quote for USDC Base -> Ethereum', async () => {
const user = '0x016f34D4f2578c3e9DFfC3f2b811Ba30c0c9e7f3'
const result = await api.getQuote({
user,
recipient: user,
originChainId: 8453,
destinationChainId: 1,
originCurrency: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // USDC on Base
destinationCurrency: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC on Ethereum
amount: '1000000', // 1 USDC
tradeType: 'EXACT_INPUT',
useDepositAddress: true,
strict: true,
refundTo: user,
})

expect(result).toBeDefined()
expect(result.steps).toBeDefined()
expect(result.steps.length).toBeGreaterThan(0)
expect(result.steps[0]!.depositAddress).toBeDefined()
expect(result.fees).toBeDefined()
expect(result.details).toBeDefined()
expect(result.details.currencyIn).toBeDefined()
expect(result.details.currencyOut).toBeDefined()
})

it('getRequests returns response for a deposit address', async () => {
const result = await api.getRequests('0x03508bB71268BBA25ECaCC8F620e01866650532c')

expect(result).toBeDefined()
expect(result).toHaveProperty('requests')
expect(Array.isArray(result.requests)).toBe(true)
})

it('getStatus returns success for a known completed request', async () => {
const result = await api.getStatus('0x4801b4fbc17f8fa11da83837bf2dabfb158bc643db89a90fca6451f69b9584eb')

expect(result).toBeDefined()
expect(result.status).toBe('success')
expect(result.inTxHashes).toBeDefined()
expect(result.txHashes).toBeDefined()
})
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
196 changes: 196 additions & 0 deletions packages/bridging/src/providers/relay/RelayApi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { RelayApi } from './RelayApi'

const BASE_URL = 'https://test.relay.link'

describe('RelayApi', () => {
const mockFetch = jest.fn()

beforeAll(() => {
global.fetch = mockFetch
})

beforeEach(() => {
mockFetch.mockReset()
})

function mockOk(data: unknown) {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => data,
} as any)
}

function mockError(status: number, body = 'Error') {
mockFetch.mockResolvedValueOnce({
ok: false,
status,
text: async () => body,
} as any)
}

describe('getCurrencies', () => {
it('sends correct POST request', async () => {
const api = new RelayApi(BASE_URL)
const currencies = [{ chainId: 8453, address: '0xusdc', symbol: 'USDC', name: 'USD Coin', decimals: 6 }]
mockOk(currencies)

const result = await api.getCurrencies({ chainIds: [8453], depositAddressOnly: true })

expect(mockFetch).toHaveBeenCalledWith(`${BASE_URL}/currencies/v2`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chainIds: [8453], depositAddressOnly: true }),
})
expect(result).toEqual(currencies)
})

it('caches results for same request', async () => {
const api = new RelayApi(BASE_URL)
mockOk([])

await api.getCurrencies({ chainIds: [1] })
await api.getCurrencies({ chainIds: [1] })

expect(mockFetch).toHaveBeenCalledTimes(1)
})

it('does not cache for different requests', async () => {
const api = new RelayApi(BASE_URL)
mockOk([])
mockOk([])

await api.getCurrencies({ chainIds: [1] })
await api.getCurrencies({ chainIds: [8453] })

expect(mockFetch).toHaveBeenCalledTimes(2)
})
})

describe('getQuote', () => {
it('sends correct POST request with useDepositAddress', async () => {
const api = new RelayApi(BASE_URL)
const quoteResponse = { steps: [], fees: {}, details: {} }
mockOk(quoteResponse)

const request = {
user: '0xuser',
originChainId: 8453,
destinationChainId: 1,
originCurrency: '0xusdc',
destinationCurrency: '0xusdc-eth',
amount: '1000000',
tradeType: 'EXACT_INPUT' as const,
useDepositAddress: true,
strict: true,
}
await api.getQuote(request)

expect(mockFetch).toHaveBeenCalledWith(`${BASE_URL}/quote/v2`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
})
})

it('enforces useDepositAddress: true and strict: true even if caller omits them', async () => {
const api = new RelayApi(BASE_URL)
mockOk({ steps: [], fees: {}, details: {} })

const request = {
user: '0xuser',
originChainId: 8453,
destinationChainId: 1,
originCurrency: '0xusdc',
destinationCurrency: '0xusdc-eth',
amount: '1000000',
tradeType: 'EXACT_INPUT' as const,
useDepositAddress: false,
strict: false,
}
await api.getQuote(request)

const sentBody = JSON.parse(mockFetch.mock.calls[0][1].body)
expect(sentBody.useDepositAddress).toBe(true)
expect(sentBody.strict).toBe(true)
})
})

describe('getStatus', () => {
it('sends correct GET request with requestId', async () => {
const api = new RelayApi(BASE_URL)
mockOk({ status: 'success' })

await api.getStatus('0xrequest123')

expect(mockFetch).toHaveBeenCalledWith(`${BASE_URL}/intents/status/v3?requestId=0xrequest123`, undefined)
})
})

describe('getRequests', () => {
it('sends correct GET request with depositAddress', async () => {
const api = new RelayApi(BASE_URL)
mockOk({ requests: [], continuation: null })

await api.getRequests('0xdeposit123')

expect(mockFetch).toHaveBeenCalledWith(
`${BASE_URL}/requests/v2?depositAddress=0xdeposit123&sortBy=createdAt&sortDirection=desc&limit=1`,
undefined,
)
})
})

describe('error handling', () => {
it('throws NO_ROUTES for 404', async () => {
const api = new RelayApi(BASE_URL)
mockError(404)

await expect(api.getCurrencies({})).rejects.toThrow('NO_ROUTES')
})

it('throws NO_ROUTES for 400', async () => {
const api = new RelayApi(BASE_URL)
mockError(400)

await expect(api.getQuote({} as any)).rejects.toThrow('NO_ROUTES')
})

it('throws API_ERROR for 500', async () => {
const api = new RelayApi(BASE_URL)
mockError(500)

await expect(api.getStatus('0x123')).rejects.toThrow('API_ERROR')
})

it('throws API_ERROR on network failure', async () => {
const api = new RelayApi(BASE_URL)
mockFetch.mockRejectedValueOnce(new Error('Network error'))

await expect(api.getStatus('0x123')).rejects.toThrow('API_ERROR')
})
})

describe('api key', () => {
it('adds x-api-key header when api key is set', async () => {
const api = new RelayApi(BASE_URL, 'test-api-key')
mockOk([])

await api.getCurrencies({ chainIds: [1] })

const [, options] = mockFetch.mock.calls[0] as [RequestInfo, RequestInit]
const headers = new Headers(options.headers)
expect(headers.get('x-api-key')).toBe('test-api-key')
})

it('does not add x-api-key header when api key is not set', async () => {
const api = new RelayApi(BASE_URL)
mockOk([])

await api.getCurrencies({ chainIds: [1] })

const [, options] = mockFetch.mock.calls[0] as [RequestInfo, RequestInit]
const headers = new Headers(options.headers)
expect(headers.get('x-api-key')).toBeNull()
})
})
})
97 changes: 97 additions & 0 deletions packages/bridging/src/providers/relay/RelayApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { BridgeProviderQuoteError, BridgeQuoteErrors } from '../../errors'
import { RELAY_API_BASE_URL } from './const'

import type {
RelayCurrenciesRequest,
RelayCurrency,
RelayQuoteRequest,
RelayQuoteResponse,
RelayRequestsResponse,
RelayStatusResponse,
} from './types'

export class RelayApi {
private baseUrl: string
private apiKey?: string
private currencyCache = new Map<string, RelayCurrency[]>()

constructor(baseUrl?: string, apiKey?: string) {
this.baseUrl = baseUrl ?? RELAY_API_BASE_URL
this.apiKey = apiKey
}

async getCurrencies(request: RelayCurrenciesRequest): Promise<RelayCurrency[]> {
const cacheKey = JSON.stringify(request)
const cached = this.currencyCache.get(cacheKey)
if (cached) return cached

const result = await this.fetchJson<RelayCurrency[]>(`${this.baseUrl}/currencies/v2`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
})

this.currencyCache.set(cacheKey, result)
return result
}

async getQuote(request: RelayQuoteRequest): Promise<RelayQuoteResponse> {
return this.fetchJson<RelayQuoteResponse>(`${this.baseUrl}/quote/v2`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...request, useDepositAddress: true, strict: true }),
})
}

async getStatus(requestId: string): Promise<RelayStatusResponse> {
return this.fetchJson<RelayStatusResponse>(
`${this.baseUrl}/intents/status/v3?requestId=${encodeURIComponent(requestId)}`,
)
}

async getRequests(depositAddress: string): Promise<RelayRequestsResponse> {
return this.fetchJson<RelayRequestsResponse>(
`${this.baseUrl}/requests/v2?depositAddress=${encodeURIComponent(depositAddress)}&sortBy=createdAt&sortDirection=desc&limit=1`,
)
}

private async fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
if (this.apiKey) {
const headers = new Headers(options?.headers)
headers.set('x-api-key', this.apiKey)
options = { ...options, headers }
}

let response: Response
try {
response = await fetch(url, options)
} catch (error) {
throw new BridgeProviderQuoteError(BridgeQuoteErrors.API_ERROR, {
message: error instanceof Error ? error.message : 'Network error',
})
}

Comment on lines +65 to +73

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Add a request timeout for Relay HTTP calls.

Line 67 can hang indefinitely on slow/stalled upstream responses. For quote paths, this is a production reliability risk and should be bounded.

⏱️ Proposed fix (AbortController timeout)
 export class RelayApi {
   private baseUrl: string
   private apiKey?: string
   private currencyCache = new Map<string, RelayCurrency[]>()
+  private readonly timeoutMs: number

-  constructor(baseUrl?: string, apiKey?: string) {
+  constructor(baseUrl?: string, apiKey?: string, timeoutMs = 15_000) {
     this.baseUrl = baseUrl ?? RELAY_API_BASE_URL
     this.apiKey = apiKey
+    this.timeoutMs = timeoutMs
   }
@@
   private async fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
@@
     let response: Response
+    const controller = new AbortController()
+    const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs)
     try {
-      response = await fetch(url, options)
+      response = await fetch(url, { ...options, signal: controller.signal })
     } catch (error) {
       throw new BridgeProviderQuoteError(BridgeQuoteErrors.API_ERROR, {
         message: error instanceof Error ? error.message : 'Network error',
       })
+    } finally {
+      clearTimeout(timeoutId)
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bridging/src/providers/relay/RelayApi.ts` around lines 65 - 73, The
fetch call in RelayApi (the block assigning response = await fetch(url,
options)) can hang; wrap the request with an AbortController-based timeout:
create an AbortController, add its signal to the fetch options passed to the
existing fetch call, start a setTimeout that calls controller.abort() after a
configured timeout (e.g., configurable constant) and clear the timeout when
fetch resolves; in the catch, detect an abort (error.name === 'AbortError' or
similar) and map it to a BridgeProviderQuoteError(BridgeQuoteErrors.API_ERROR)
with a timeout-specific message, otherwise preserve the existing network error
handling; update the function in RelayApi.ts that performs the fetch to use this
pattern.

if (!response.ok) {
let body: string | undefined
try {
body = await response.text()
} catch {
// ignore
}

if (response.status === 404 || response.status === 400) {
throw new BridgeProviderQuoteError(BridgeQuoteErrors.NO_ROUTES, {
status: response.status,
body,
})
}

throw new BridgeProviderQuoteError(BridgeQuoteErrors.API_ERROR, {
status: response.status,
body,
})
}

return (await response.json()) as T

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Normalize JSON parse failures to INVALID_API_JSON_RESPONSE.

Line 95 currently lets SyntaxError escape directly if Relay responds with invalid JSON, bypassing your bridge-level error model.

🧩 Proposed fix
-    return (await response.json()) as T
+    try {
+      return (await response.json()) as T
+    } catch (error) {
+      throw new BridgeProviderQuoteError(BridgeQuoteErrors.INVALID_API_JSON_RESPONSE, {
+        url,
+        message: error instanceof Error ? error.message : 'Invalid JSON response',
+      })
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bridging/src/providers/relay/RelayApi.ts` at line 95, The code
currently returns (await response.json()) as T and lets JSON parse errors
(SyntaxError) bubble up; wrap the response.json() call in a try/catch inside the
RelayApi method that performs the fetch, catch JSON/SyntaxError and throw the
bridge-level normalized error type INVALID_API_JSON_RESPONSE (preserving
original error details in the new error), so callers always receive the
standardized error instead of raw SyntaxError when Relay responds with invalid
JSON.

}
}
Loading