-
Notifications
You must be signed in to change notification settings - Fork 48
feat(bridging): add Relay bridge provider #846
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
f954b86
f2eae33
e7d868d
35bfb01
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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() | ||
| }) | ||
| }) | ||
| 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() | ||
| }) | ||
| }) | ||
| }) |
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 |
||
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Normalize JSON parse failures to Line 95 currently lets 🧩 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 |
||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.