diff --git a/packages/bridging/src/index.ts b/packages/bridging/src/index.ts index ad4958d29..7722f1482 100644 --- a/packages/bridging/src/index.ts +++ b/packages/bridging/src/index.ts @@ -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' diff --git a/packages/bridging/src/providers/relay/RelayApi.spec.ts b/packages/bridging/src/providers/relay/RelayApi.spec.ts new file mode 100644 index 000000000..4ca73d21c --- /dev/null +++ b/packages/bridging/src/providers/relay/RelayApi.spec.ts @@ -0,0 +1,77 @@ +import { RelayApi } from './RelayApi' + +const describeIntegration = process.env.RELAY_INTEGRATION_TESTS ? describe : describe.skip + +describeIntegration('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() + }) +}) diff --git a/packages/bridging/src/providers/relay/RelayApi.test.ts b/packages/bridging/src/providers/relay/RelayApi.test.ts new file mode 100644 index 000000000..6c13df5a1 --- /dev/null +++ b/packages/bridging/src/providers/relay/RelayApi.test.ts @@ -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() + }) + }) +}) diff --git a/packages/bridging/src/providers/relay/RelayApi.ts b/packages/bridging/src/providers/relay/RelayApi.ts new file mode 100644 index 000000000..03fc8e0ff --- /dev/null +++ b/packages/bridging/src/providers/relay/RelayApi.ts @@ -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() + + constructor(baseUrl?: string, apiKey?: string) { + this.baseUrl = baseUrl ?? RELAY_API_BASE_URL + this.apiKey = apiKey + } + + async getCurrencies(request: RelayCurrenciesRequest): Promise { + const cacheKey = JSON.stringify(request) + const cached = this.currencyCache.get(cacheKey) + if (cached) return cached + + const result = await this.fetchJson(`${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 { + return this.fetchJson(`${this.baseUrl}/quote/v2`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...request, useDepositAddress: true, strict: true }), + }) + } + + async getStatus(requestId: string): Promise { + return this.fetchJson( + `${this.baseUrl}/intents/status/v3?requestId=${encodeURIComponent(requestId)}`, + ) + } + + async getRequests(depositAddress: string): Promise { + return this.fetchJson( + `${this.baseUrl}/requests/v2?depositAddress=${encodeURIComponent(depositAddress)}&sortBy=createdAt&sortDirection=desc&limit=1`, + ) + } + + private async fetchJson(url: string, options?: RequestInit): Promise { + 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', + }) + } + + 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 + } +} diff --git a/packages/bridging/src/providers/relay/RelayBridgeProvider.test.ts b/packages/bridging/src/providers/relay/RelayBridgeProvider.test.ts new file mode 100644 index 000000000..949962d08 --- /dev/null +++ b/packages/bridging/src/providers/relay/RelayBridgeProvider.test.ts @@ -0,0 +1,353 @@ +import { OrderKind } from '@cowprotocol/sdk-order-book' + +import { BridgeStatus } from '../../types' +import { RelayBridgeProvider } from './RelayBridgeProvider' +import { RELAY_SUPPORTED_NETWORKS } from './const' + +import type { RelayQuoteResponse, RelayStatusResponse } from './types' + +// Subclass to expose protected api for mocking +class TestRelayBridgeProvider extends RelayBridgeProvider { + get testApi() { + return this.api + } +} + +function mockQuoteResponse(): RelayQuoteResponse { + return { + steps: [ + { + id: 'deposit', + action: 'Confirm transaction', + description: 'Depositing funds', + kind: 'transaction', + requestId: '0xrequest123', + depositAddress: '0xdeposit456', + items: [], + }, + ], + fees: { + gas: { + currency: { chainId: 8453, address: '0x0', symbol: 'ETH', name: 'Ether', decimals: 18 }, + amount: '100000', + amountFormatted: '0.0001', + amountUsd: '0.20', + }, + relayer: { + currency: { chainId: 8453, address: '0xusdc', symbol: 'USDC', name: 'USD Coin', decimals: 6 }, + amount: '5000', + amountFormatted: '0.005', + amountUsd: '0.005', + }, + relayerGas: { + currency: { chainId: 1, address: '0x0', symbol: 'ETH', name: 'Ether', decimals: 18 }, + amount: '80000', + amountFormatted: '0.00008', + amountUsd: '0.16', + }, + relayerService: { + currency: { chainId: 8453, address: '0xusdc', symbol: 'USDC', name: 'USD Coin', decimals: 6 }, + amount: '2000', + amountFormatted: '0.002', + amountUsd: '0.002', + }, + }, + details: { + operation: 'bridge', + sender: '0xsender', + recipient: '0xrecipient', + currencyIn: { + currency: { chainId: 8453, address: '0xusdc', symbol: 'USDC', name: 'USD Coin', decimals: 6 }, + amount: '1000000', + amountFormatted: '1.0', + amountUsd: '1.00', + minimumAmount: '990000', + }, + currencyOut: { + currency: { chainId: 1, address: '0xusdc-eth', symbol: 'USDC', name: 'USD Coin', decimals: 6 }, + amount: '995000', + amountFormatted: '0.995', + amountUsd: '0.995', + minimumAmount: '985000', + }, + rate: '0.995', + timeEstimate: 15, + }, + } +} + +describe('RelayBridgeProvider', () => { + let provider: TestRelayBridgeProvider + + beforeEach(() => { + provider = new TestRelayBridgeProvider({ baseUrl: 'https://test.relay.link' }) + }) + + it('passes apiKey to RelayApi which adds x-api-key header', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => [], + }) + const originalFetch = global.fetch + global.fetch = mockFetch + + try { + const providerWithKey = new TestRelayBridgeProvider({ baseUrl: 'https://test.relay.link', apiKey: 'test-api-key' }) + await providerWithKey.testApi.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') + } finally { + global.fetch = originalFetch + } + }) + + describe('info', () => { + it('has correct provider info', () => { + expect(provider.info.name).toBe('Relay') + expect(provider.type).toBe('ReceiverAccountBridgeProvider') + expect(provider.info.website).toBe('https://relay.link') + }) + }) + + describe('getNetworks', () => { + it('returns all 11 supported networks', async () => { + const networks = await provider.getNetworks() + expect(networks).toBe(RELAY_SUPPORTED_NETWORKS) + expect(networks).toHaveLength(11) + }) + }) + + describe('getBuyTokens', () => { + it('returns tokens for supported chain', async () => { + const currencies = [ + { chainId: 8453, address: '0xusdc', symbol: 'USDC', name: 'USD Coin', decimals: 6 }, + ] + jest.spyOn(provider.testApi, 'getCurrencies').mockResolvedValue(currencies) + + const result = await provider.getBuyTokens({ buyChainId: 8453 }) + expect(result.isRouteAvailable).toBe(true) + expect(result.tokens).toHaveLength(1) + expect(result.tokens[0]?.symbol).toBe('USDC') + }) + + it('returns empty for unsupported chain', async () => { + const result = await provider.getBuyTokens({ buyChainId: 999999 as any }) + expect(result.isRouteAvailable).toBe(false) + expect(result.tokens).toHaveLength(0) + }) + }) + + describe('getIntermediateTokens', () => { + it('throws on non-sell orders', async () => { + await expect( + provider.getIntermediateTokens({ kind: OrderKind.BUY } as any), + ).rejects.toThrow('ONLY_SELL_ORDER_SUPPORTED') + }) + + it('returns source tokens when buy token is available on dest', async () => { + const sourceCurrencies = [ + { chainId: 8453, address: '0xusdc', symbol: 'USDC', name: 'USD Coin', decimals: 6 }, + ] + const destCurrencies = [ + { chainId: 1, address: '0xusdc-eth', symbol: 'USDC', name: 'USD Coin', decimals: 6 }, + ] + jest.spyOn(provider.testApi, 'getCurrencies') + .mockResolvedValueOnce(sourceCurrencies) + .mockResolvedValueOnce(destCurrencies) + + const result = await provider.getIntermediateTokens({ + kind: OrderKind.SELL, + sellTokenChainId: 8453, + buyTokenChainId: 1, + buyTokenAddress: '0xusdc-eth', + } as any) + + expect(result).toHaveLength(1) + }) + }) + + describe('getQuote', () => { + it('returns correct RelayQuoteResult', async () => { + jest.spyOn(provider.testApi, 'getQuote').mockResolvedValue(mockQuoteResponse()) + + const result = await provider.getQuote({ + kind: OrderKind.SELL, + sellTokenAddress: '0xusdc', + sellTokenChainId: 8453, + buyTokenAddress: '0xusdc-eth', + buyTokenChainId: 1, + amount: BigInt(1000000), + account: '0xuser', + } as any) + + expect(result.requestId).toBe('0xrequest123') + expect(result.depositAddress).toBe('0xdeposit456') + expect(result.timeEstimate).toBe(15) + expect(result.isSell).toBe(true) + expect(result.fees.bridgeFee).toBe(BigInt(5000)) + expect(result.fees.destinationGasFee).toBe(BigInt(80000)) + expect(result.limits.minDeposit).toBe(BigInt(990000)) + expect(result.limits.maxDeposit).toBe(BigInt(1000000)) + expect(result.quoteBody).toBeDefined() + }) + + it('throws NO_ROUTES when no deposit address', async () => { + const response = mockQuoteResponse() + response.steps[0].depositAddress = undefined + jest.spyOn(provider.testApi, 'getQuote').mockResolvedValue(response) + + await expect( + provider.getQuote({ + kind: OrderKind.SELL, + sellTokenAddress: '0xusdc', + sellTokenChainId: 8453, + buyTokenAddress: '0xusdc-eth', + buyTokenChainId: 1, + amount: BigInt(1000000), + account: '0xuser', + } as any), + ).rejects.toThrow('NO_ROUTES') + }) + }) + + describe('getBridgeReceiverOverride', () => { + it('returns deposit address from quote', async () => { + const result = await provider.getBridgeReceiverOverride({} as any, { + depositAddress: '0xdeposit', + } as any) + expect(result).toBe('0xdeposit') + }) + }) + + describe('getBridgingParams', () => { + function makeOrderWithQuoteBody(quoteBody: object, overrides: Record = {}) { + return { + receiver: '0xdeposit', + owner: '0xowner', + fullAppData: JSON.stringify({ + metadata: { bridging: { quoteBody: JSON.stringify(quoteBody) } }, + }), + ...overrides, + } as any + } + + it('returns null when order has no receiver', async () => { + const result = await provider.getBridgingParams(8453 as any, { receiver: undefined, owner: '0xowner' } as any, '0xtx') + expect(result).toBeNull() + }) + + it('returns null when no quoteBody in fullAppData', async () => { + const result = await provider.getBridgingParams(8453 as any, { receiver: '0xdeposit', owner: '0xowner', fullAppData: '{}' } as any, '0xtx') + expect(result).toBeNull() + }) + + it('extracts params from quoteBody', async () => { + jest.spyOn(provider.testApi, 'getStatus').mockResolvedValue({ status: 'success', inTxHashes: ['0xin'], txHashes: ['0xout'] }) + + const order = makeOrderWithQuoteBody({ + steps: [{ requestId: '0xreq1', depositAddress: '0xdeposit' }], + details: { + currencyIn: { currency: { chainId: 8453, address: '0xusdc-base', symbol: 'USDC', name: 'USDC', decimals: 6 }, amount: '1000000', amountFormatted: '1', amountUsd: '1' }, + currencyOut: { currency: { chainId: 1, address: '0xusdc-eth', symbol: 'USDC', name: 'USDC', decimals: 6 }, amount: '990000', amountFormatted: '0.99', amountUsd: '0.99' }, + timeEstimate: 15, + }, + }) + + const result = await provider.getBridgingParams(8453 as any, order, '0xtx') + + expect(result).not.toBeNull() + if (!result) return + expect(result.params.bridgingId).toBe('0xreq1') + expect(result.params.inputTokenAddress).toBe('0xusdc-base') + expect(result.params.outputTokenAddress).toBe('0xusdc-eth') + expect(result.params.inputAmount).toBe(BigInt(1000000)) + expect(result.params.outputAmount).toBe(BigInt(990000)) + expect(result.params.sourceChainId).toBe(8453) + expect(result.params.destinationChainId).toBe(1) + expect(result.status.status).toBe(BridgeStatus.EXECUTED) + }) + + it('maps native address in getBridgingParams', async () => { + jest.spyOn(provider.testApi, 'getStatus').mockResolvedValue({ status: 'pending' }) + + const order = makeOrderWithQuoteBody({ + steps: [{ requestId: '0xreq2', depositAddress: '0xdeposit' }], + details: { + currencyIn: { currency: { chainId: 8453, address: '0x0000000000000000000000000000000000000000', symbol: 'ETH', name: 'Ether', decimals: 18 }, amount: '1000000000000000000', amountFormatted: '1', amountUsd: '2000' }, + currencyOut: { currency: { chainId: 1, address: '0xusdc', symbol: 'USDC', name: 'USDC', decimals: 6 }, amount: '2000000000', amountFormatted: '2000', amountUsd: '2000' }, + timeEstimate: 30, + }, + }) + + const result = await provider.getBridgingParams(8453 as any, order, '0xtx') + + expect(result).not.toBeNull() + if (!result) return + expect(result.params.inputTokenAddress).toBe('0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE') + }) + }) + + describe('getExplorerUrl', () => { + it('returns correct URL', () => { + expect(provider.getExplorerUrl('0xbridge123')).toBe('https://relay.link/transaction/0xbridge123') + }) + }) + + describe('getStatus', () => { + it('maps success status correctly', async () => { + jest.spyOn(provider.testApi, 'getStatus').mockResolvedValue({ + status: 'success', + inTxHashes: ['0xin'], + txHashes: ['0xout'], + } as RelayStatusResponse) + + const result = await provider.getStatus('0xid', 8453 as any) + expect(result.status).toBe(BridgeStatus.EXECUTED) + expect(result.depositTxHash).toBe('0xin') + expect(result.fillTxHash).toBe('0xout') + }) + + it('maps refund status correctly', async () => { + jest.spyOn(provider.testApi, 'getStatus').mockResolvedValue({ status: 'refund' } as RelayStatusResponse) + + const result = await provider.getStatus('0xid', 8453 as any) + expect(result.status).toBe(BridgeStatus.REFUND) + }) + + it('maps failure status to EXPIRED', async () => { + jest.spyOn(provider.testApi, 'getStatus').mockResolvedValue({ status: 'failure' } as RelayStatusResponse) + + const result = await provider.getStatus('0xid', 8453 as any) + expect(result.status).toBe(BridgeStatus.EXPIRED) + }) + + it('maps in-progress statuses correctly', async () => { + for (const status of ['waiting', 'depositing', 'pending', 'submitted']) { + jest.spyOn(provider.testApi, 'getStatus').mockResolvedValue({ status } as RelayStatusResponse) + const result = await provider.getStatus('0xid', 8453 as any) + expect(result.status).toBe(BridgeStatus.IN_PROGRESS) + } + }) + + it('returns UNKNOWN on error', async () => { + jest.spyOn(provider.testApi, 'getStatus').mockRejectedValue(new Error('fail')) + + const result = await provider.getStatus('0xid', 8453 as any) + expect(result.status).toBe(BridgeStatus.UNKNOWN) + }) + }) + + describe('getCancelBridgingTx', () => { + it('throws not implemented', () => { + expect(() => provider.getCancelBridgingTx('0x')).toThrow('Not implemented') + }) + }) + + describe('getRefundBridgingTx', () => { + it('throws not implemented', () => { + expect(() => provider.getRefundBridgingTx('0x')).toThrow('Not implemented') + }) + }) +}) diff --git a/packages/bridging/src/providers/relay/RelayBridgeProvider.ts b/packages/bridging/src/providers/relay/RelayBridgeProvider.ts new file mode 100644 index 000000000..01909d1c4 --- /dev/null +++ b/packages/bridging/src/providers/relay/RelayBridgeProvider.ts @@ -0,0 +1,250 @@ +import { OrderKind } from '@cowprotocol/sdk-order-book' + +import { DEFAULT_BRIDGE_SLIPPAGE_BPS, RAW_PROVIDERS_FILES_PATH } from '../../const' +import { BridgeProviderQuoteError, BridgeQuoteErrors } from '../../errors' +import { BridgeStatus } from '../../types' +import { RelayApi } from './RelayApi' +import { + RELAY_HOOK_DAPP_ID, + RELAY_STATUS_TO_COW_STATUS, + RELAY_SUPPORTED_CHAIN_IDS, + RELAY_SUPPORTED_NETWORKS, +} from './const' +import { computeFeeBps, computeFeeInBuyCurrency, computeSlippageBps, fromRelayAddress, mapRelayCurrencyToTokenInfo, toRelayAddress } from './util' + +import type { RelayQuoteResponse } from './types' + +import type { ChainId, ChainInfo, EvmCall, SupportedChainId, TokenInfo } from '@cowprotocol/sdk-config' +import type { EnrichedOrder } from '@cowprotocol/sdk-order-book' +import type { + BridgeProviderInfo, + BridgeQuoteResult, + BridgeStatusResult, + BridgingDepositParams, + BuyTokensParams, + GetProviderBuyTokens, + QuoteBridgeRequest, + ReceiverAccountBridgeProvider, +} from '../../types' + +const providerType = 'ReceiverAccountBridgeProvider' as const + +export interface RelayQuoteResult extends BridgeQuoteResult { + requestId: string + depositAddress: string + timeEstimate: number +} + +export interface RelayBridgeProviderOptions { + baseUrl?: string + apiKey?: string +} + +export class RelayBridgeProvider implements ReceiverAccountBridgeProvider { + type = providerType + + protected api: RelayApi + + info: BridgeProviderInfo = { + name: 'Relay', + logoUrl: `${RAW_PROVIDERS_FILES_PATH}/relay/relay-logo.png`, + dappId: RELAY_HOOK_DAPP_ID, + website: 'https://relay.link', + type: providerType, + } + + constructor(options?: RelayBridgeProviderOptions) { + this.api = new RelayApi(options?.baseUrl, options?.apiKey) + } + + async getNetworks(): Promise { + return RELAY_SUPPORTED_NETWORKS + } + + async getBuyTokens(params: BuyTokensParams): Promise { + if (!RELAY_SUPPORTED_CHAIN_IDS.has(params.buyChainId as number)) { + return { tokens: [], isRouteAvailable: false } + } + + const currencies = await this.api.getCurrencies({ + chainIds: [params.buyChainId as number], + depositAddressOnly: true, + }) + + const tokens = currencies.map(mapRelayCurrencyToTokenInfo) + return { tokens, isRouteAvailable: tokens.length > 0 } + } + + async getIntermediateTokens(request: QuoteBridgeRequest): Promise { + if (request.kind !== OrderKind.SELL) { + throw new BridgeProviderQuoteError(BridgeQuoteErrors.ONLY_SELL_ORDER_SUPPORTED, { kind: request.kind }) + } + + const { sellTokenChainId, buyTokenChainId, buyTokenAddress } = request + + const [sourceCurrencies, destCurrencies] = await Promise.all([ + this.api.getCurrencies({ chainIds: [sellTokenChainId], depositAddressOnly: true }), + this.api.getCurrencies({ chainIds: [buyTokenChainId as number], depositAddressOnly: true }), + ]) + + const destAddresses = new Set(destCurrencies.map((c) => c.address.toLowerCase())) + + if (!destAddresses.has(toRelayAddress(buyTokenAddress).toLowerCase())) { + return [] + } + + return sourceCurrencies.map(mapRelayCurrencyToTokenInfo) + } + + async getQuote(request: QuoteBridgeRequest): Promise { + if (request.kind !== OrderKind.SELL) { + throw new BridgeProviderQuoteError(BridgeQuoteErrors.ONLY_SELL_ORDER_SUPPORTED, { kind: request.kind }) + } + + const { sellTokenAddress, sellTokenChainId, buyTokenAddress, buyTokenChainId, amount, owner, account, receiver } = + request + + const relayResponse = await this.api.getQuote({ + user: owner ?? account, + recipient: receiver ?? account, + originChainId: sellTokenChainId, + destinationChainId: buyTokenChainId as number, + originCurrency: toRelayAddress(sellTokenAddress), + destinationCurrency: toRelayAddress(buyTokenAddress), + amount: amount.toString(), + tradeType: 'EXACT_INPUT', + useDepositAddress: true, + strict: true, + refundTo: owner ?? account, + slippageTolerance: (request.bridgeSlippageBps ?? DEFAULT_BRIDGE_SLIPPAGE_BPS).toString(), + }) + + const step = relayResponse.steps[0] + if (!step?.depositAddress) { + throw new BridgeProviderQuoteError(BridgeQuoteErrors.NO_ROUTES) + } + + const { details, fees } = relayResponse + const requestId = step.requestId + + return { + id: requestId, + requestId, + depositAddress: step.depositAddress, + timeEstimate: details.timeEstimate, + quoteBody: JSON.stringify(relayResponse), + isSell: request.kind === OrderKind.SELL, + quoteTimestamp: Math.floor(Date.now() / 1000), + expectedFillTimeSeconds: details.timeEstimate, + limits: { + minDeposit: BigInt(details.currencyIn.minimumAmount ?? details.currencyIn.amount), + maxDeposit: BigInt(details.currencyIn.amount), + }, + fees: { + bridgeFee: BigInt(fees.relayer.amount), + destinationGasFee: BigInt(fees.relayerGas.amount), + }, + amountsAndCosts: { + beforeFee: { + sellAmount: BigInt(details.currencyIn.amount), + buyAmount: BigInt(details.currencyOut.amount), + }, + afterFee: { + sellAmount: BigInt(details.currencyIn.amount), + buyAmount: BigInt(details.currencyOut.minimumAmount ?? details.currencyOut.amount), + }, + afterSlippage: { + sellAmount: BigInt(details.currencyIn.amount), + buyAmount: BigInt(details.currencyOut.minimumAmount ?? details.currencyOut.amount), + }, + slippageBps: computeSlippageBps(details), + costs: { + bridgingFee: { + feeBps: computeFeeBps(details, fees), + amountInSellCurrency: BigInt(fees.relayer.amount), + amountInBuyCurrency: computeFeeInBuyCurrency(fees, details), + }, + }, + }, + } + } + + async getBridgeReceiverOverride(_request: QuoteBridgeRequest, quote: RelayQuoteResult): Promise { + return quote.depositAddress + } + + async getBridgingParams( + _chainId: ChainId, + order: EnrichedOrder, + _txHash: string, + ): Promise<{ params: BridgingDepositParams; status: BridgeStatusResult } | null> { + if (!order.receiver) return null + + const quoteBody = this.extractQuoteBodyFromOrder(order) + if (!quoteBody) return null + + const requestId = quoteBody.steps?.[0]?.requestId + if (!requestId) return null + + const details = quoteBody.details + const inputCurrency = details.currencyIn.currency + const outputCurrency = details.currencyOut.currency + const timeEstimate = details.timeEstimate ?? 30 + + const statusResult = await this.getStatus(requestId, _chainId as SupportedChainId) + const quoteTimestamp = Math.floor(Date.now() / 1000) + + return { + status: statusResult, + params: { + inputTokenAddress: fromRelayAddress(inputCurrency.address) as `0x${string}`, + outputTokenAddress: fromRelayAddress(outputCurrency.address) as `0x${string}`, + inputAmount: BigInt(details.currencyIn.amount), + outputAmount: BigInt(details.currencyOut.amount), + owner: order.owner, + quoteTimestamp, + fillDeadline: quoteTimestamp + timeEstimate, + recipient: order.owner as `0x${string}`, + sourceChainId: inputCurrency.chainId, + destinationChainId: outputCurrency.chainId, + bridgingId: requestId, + }, + } + } + + private extractQuoteBodyFromOrder(order: EnrichedOrder): RelayQuoteResponse | null { + try { + const appData = JSON.parse(order.fullAppData ?? '{}') + const quoteBody = appData?.metadata?.bridging?.quoteBody + if (!quoteBody) return null + return JSON.parse(quoteBody) + } catch { + return null + } + } + + getExplorerUrl(bridgingId: string): string { + return `https://relay.link/transaction/${bridgingId}` + } + + async getStatus(bridgingId: string, _originChainId: SupportedChainId): Promise { + try { + const response = await this.api.getStatus(bridgingId) + return { + status: RELAY_STATUS_TO_COW_STATUS[response.status] ?? BridgeStatus.UNKNOWN, + depositTxHash: response.inTxHashes?.[0], + fillTxHash: response.txHashes?.[0], + } + } catch { + return { status: BridgeStatus.UNKNOWN } + } + } + + getCancelBridgingTx(_bridgingId: string): Promise { + throw new Error('Not implemented') + } + + getRefundBridgingTx(_bridgingId: string): Promise { + throw new Error('Not implemented') + } +} diff --git a/packages/bridging/src/providers/relay/const/index.ts b/packages/bridging/src/providers/relay/const/index.ts new file mode 100644 index 000000000..7f4eeee09 --- /dev/null +++ b/packages/bridging/src/providers/relay/const/index.ts @@ -0,0 +1,46 @@ +import { + arbitrumOne, + avalanche, + base, + bnb, + gnosisChain, + ink, + linea, + mainnet, + optimism, + plasma, + polygon, +} from '@cowprotocol/sdk-config' + +import { HOOK_DAPP_BRIDGE_PROVIDER_PREFIX } from '../../../const' +import { BridgeStatus } from '../../../types' + +export const RELAY_API_BASE_URL = 'https://api.relay.link' + +export const RELAY_HOOK_DAPP_ID = `${HOOK_DAPP_BRIDGE_PROVIDER_PREFIX}/relay` + +export const RELAY_SUPPORTED_NETWORKS = [ + mainnet, + optimism, + base, + arbitrumOne, + polygon, + bnb, + avalanche, + linea, + plasma, + ink, + gnosisChain, +] + +export const RELAY_SUPPORTED_CHAIN_IDS = new Set(RELAY_SUPPORTED_NETWORKS.map((n) => n.id)) + +export const RELAY_STATUS_TO_COW_STATUS: Record = { + waiting: BridgeStatus.IN_PROGRESS, + depositing: BridgeStatus.IN_PROGRESS, + pending: BridgeStatus.IN_PROGRESS, + submitted: BridgeStatus.IN_PROGRESS, + success: BridgeStatus.EXECUTED, + refund: BridgeStatus.REFUND, + failure: BridgeStatus.EXPIRED, +} diff --git a/packages/bridging/src/providers/relay/relay-logo.png b/packages/bridging/src/providers/relay/relay-logo.png new file mode 100644 index 000000000..6cc6279b2 Binary files /dev/null and b/packages/bridging/src/providers/relay/relay-logo.png differ diff --git a/packages/bridging/src/providers/relay/types.ts b/packages/bridging/src/providers/relay/types.ts new file mode 100644 index 000000000..c6a824ab9 --- /dev/null +++ b/packages/bridging/src/providers/relay/types.ts @@ -0,0 +1,172 @@ +// --- /currencies/v2 --- + +export interface RelayCurrenciesRequest { + chainIds?: number[] + term?: string + address?: string + verified?: boolean + limit?: number + depositAddressOnly?: boolean +} + +export interface RelayCurrency { + chainId: number + address: string + symbol: string + name: string + decimals: number + metadata?: { + logoURI?: string + verified?: boolean + isNative?: boolean + } +} + +// --- /quote/v2 --- + +export interface RelayQuoteRequest { + user: string + recipient?: string + originChainId: number + destinationChainId: number + originCurrency: string + destinationCurrency: string + amount: string + tradeType: 'EXACT_INPUT' | 'EXACT_OUTPUT' | 'EXPECTED_OUTPUT' + useDepositAddress: boolean + strict?: boolean + refundTo?: string + slippageTolerance?: string + referrer?: string + appFees?: Array<{ recipient: string; fee: string }> +} + +export interface RelayQuoteResponse { + steps: RelayStep[] + fees: RelayFees + details: RelayQuoteDetails + protocol?: RelayProtocol +} + +export interface RelayStep { + id: string + action: string + description: string + kind: string + requestId: string + depositAddress?: string + items: RelayStepItem[] +} + +export interface RelayStepItem { + status: string + data: { + from: string + to: string + data: string + value: string + chainId: number + } + check?: { + endpoint: string + method: string + } +} + +export interface RelayCurrencyAmount { + currency: RelayCurrency + amount: string + amountFormatted: string + amountUsd: string + minimumAmount?: string +} + +export interface RelayFees { + /** Origin chain gas fee */ + gas: RelayCurrencyAmount + /** Total relayer fee (capital + gas + service) */ + relayer: RelayCurrencyAmount + /** Destination chain gas fee */ + relayerGas: RelayCurrencyAmount + /** Relayer service fee */ + relayerService: RelayCurrencyAmount + /** App fee (if configured) */ + app?: RelayCurrencyAmount +} + +export interface RelayQuoteDetails { + operation: string + sender: string + recipient: string + currencyIn: RelayCurrencyAmount + currencyOut: RelayCurrencyAmount + rate: string + timeEstimate: number + slippageTolerance?: { + origin?: { usd: string; value: string; percent: string } + destination?: { usd: string; value: string; percent: string } + } +} + +export interface RelayProtocol { + v2?: { + orderId?: string + orderData?: unknown + } +} + +// --- /intents/status/v3 --- + +export interface RelayStatusResponse { + status: string + details?: string + inTxHashes?: string[] + txHashes?: string[] + updatedAt?: number + originChainId?: number + destinationChainId?: number +} + +// --- /requests/v2 --- + +export interface RelayRequestsResponse { + requests: RelayRequest[] + continuation?: string | null +} + +export interface RelayRequest { + id: string + status: string + user: string + recipient: string + createdAt?: string + updatedAt?: string + data: { + fees: { gas: string; fixed: string; price: string } + feesUsd: { gas: string; fixed: string; price: string } + inTxs: RelayTx[] + outTxs: RelayTx[] + currency: string + price?: string + metadata?: { + currencyIn?: RelayCurrencyAmount + currencyOut?: RelayCurrencyAmount + sender?: string + recipient?: string + rate?: string + } + } +} + +/** Best-effort type derived from observed API responses */ +export interface RelayTx { + hash: string + chainId: number + timestamp: number + data: { + from: string + to: string + value: string + data?: string + } +} diff --git a/packages/bridging/src/providers/relay/util.test.ts b/packages/bridging/src/providers/relay/util.test.ts new file mode 100644 index 000000000..5d6112209 --- /dev/null +++ b/packages/bridging/src/providers/relay/util.test.ts @@ -0,0 +1,164 @@ +import { ETH_ADDRESS } from '@cowprotocol/sdk-config' + +import { computeFeeBps, computeFeeInBuyCurrency, computeSlippageBps, fromRelayAddress, mapRelayCurrencyToTokenInfo, toRelayAddress } from './util' + +import type { RelayCurrency, RelayFees, RelayQuoteDetails } from './types' + +function makeDetails(overrides: Partial = {}): RelayQuoteDetails { + return { + operation: 'bridge', + sender: '0xsender', + recipient: '0xrecipient', + currencyIn: { + currency: { chainId: 8453, address: '0xusdc', symbol: 'USDC', name: 'USD Coin', decimals: 6 }, + amount: '1000000', + amountFormatted: '1.0', + amountUsd: '1.00', + }, + currencyOut: { + currency: { chainId: 1, address: '0xusdc-eth', symbol: 'USDC', name: 'USD Coin', decimals: 6 }, + amount: '990000', + amountFormatted: '0.99', + amountUsd: '0.99', + }, + rate: '0.99', + timeEstimate: 15, + ...overrides, + } +} + +function makeFees(overrides: Partial = {}): RelayFees { + const defaultAmount = { + currency: { chainId: 8453, address: '0x0', symbol: 'ETH', name: 'Ether', decimals: 18 }, + amount: '10000', + amountFormatted: '0.00001', + amountUsd: '0.02', + } + return { + gas: { ...defaultAmount }, + relayer: { ...defaultAmount, amount: '5000', amountUsd: '0.01' }, + relayerGas: { ...defaultAmount, amount: '3000', amountUsd: '0.005' }, + relayerService: { ...defaultAmount, amount: '2000', amountUsd: '0.005' }, + ...overrides, + } +} + +describe('computeSlippageBps', () => { + it('uses slippageTolerance.destination.percent when available', () => { + const details = makeDetails({ + slippageTolerance: { + destination: { usd: '0.50', value: '5000', percent: '0.50' }, + }, + }) + // 0.50% * 100 = 50 bps + expect(computeSlippageBps(details)).toBe(50) + }) + + it('falls back to USD values when slippageTolerance is absent', () => { + const details = makeDetails() + // (1 - 0.99/1.00) * 10000 = 100 bps + expect(computeSlippageBps(details)).toBe(100) + }) + + it('returns 0 when input USD is 0', () => { + const details = makeDetails({ + currencyIn: { ...makeDetails().currencyIn, amountUsd: '0' }, + }) + expect(computeSlippageBps(details)).toBe(0) + }) + + it('returns 0 when output equals input', () => { + const details = makeDetails({ + currencyOut: { ...makeDetails().currencyOut, amountUsd: '1.00' }, + }) + expect(computeSlippageBps(details)).toBe(0) + }) +}) + +describe('computeFeeBps', () => { + it('computes fee bps from relayer USD vs input USD', () => { + const details = makeDetails() + const fees = makeFees() + // 0.01 / 1.00 * 10000 = 100 bps + expect(computeFeeBps(details, fees)).toBe(100) + }) + + it('returns 0 when input USD is 0', () => { + const details = makeDetails({ + currencyIn: { ...makeDetails().currencyIn, amountUsd: '0' }, + }) + expect(computeFeeBps(details, makeFees())).toBe(0) + }) +}) + +describe('computeFeeInBuyCurrency', () => { + it('computes fee amount in buy currency using bigint arithmetic', () => { + const details = makeDetails() + const fees = makeFees() + // feeAmount=5000, buyAmount=990000, inAmount=1000000 + // (5000 * 990000) / 1000000 = 4950 + expect(computeFeeInBuyCurrency(fees, details)).toBe(BigInt(4950)) + }) + + it('returns 0 when input amount is 0', () => { + const details = makeDetails({ + currencyIn: { ...makeDetails().currencyIn, amount: '0' }, + }) + expect(computeFeeInBuyCurrency(makeFees(), details)).toBe(BigInt(0)) + }) +}) + +describe('mapRelayCurrencyToTokenInfo', () => { + it('maps ERC-20 currency to TokenInfo', () => { + const currency: RelayCurrency = { + chainId: 8453, + address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + } + const result = mapRelayCurrencyToTokenInfo(currency) + expect(result.address).toBe('0x833589fcd6edb6e08f4c7c32d4f71b54bda02913') + expect(result.chainId).toBe(8453) + expect(result.symbol).toBe('USDC') + }) + + it('maps native 0x0 address to ETH_ADDRESS', () => { + const currency: RelayCurrency = { + chainId: 1, + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ether', + decimals: 18, + } + const result = mapRelayCurrencyToTokenInfo(currency) + expect(result.address).toBe(ETH_ADDRESS) + }) +}) + +describe('fromRelayAddress', () => { + it('converts Relay native address to ETH_ADDRESS', () => { + expect(fromRelayAddress('0x0000000000000000000000000000000000000000')).toBe(ETH_ADDRESS) + }) + + it('converts mixed-case Relay native address to ETH_ADDRESS', () => { + expect(fromRelayAddress('0x0000000000000000000000000000000000000000')).toBe(ETH_ADDRESS) + expect(fromRelayAddress('0X0000000000000000000000000000000000000000')).toBe(ETH_ADDRESS) + }) + + it('passes through ERC-20 addresses unchanged', () => { + const addr = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913' + expect(fromRelayAddress(addr)).toBe(addr) + }) +}) + +describe('toRelayAddress', () => { + it('converts ETH_ADDRESS to Relay native address', () => { + expect(toRelayAddress(ETH_ADDRESS)).toBe('0x0000000000000000000000000000000000000000') + }) + + it('passes through ERC-20 addresses unchanged', () => { + const addr = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913' + expect(toRelayAddress(addr)).toBe(addr) + }) +}) diff --git a/packages/bridging/src/providers/relay/util.ts b/packages/bridging/src/providers/relay/util.ts new file mode 100644 index 000000000..29340aab8 --- /dev/null +++ b/packages/bridging/src/providers/relay/util.ts @@ -0,0 +1,66 @@ +import { ETH_ADDRESS } from '@cowprotocol/sdk-config' +import type { TokenInfo } from '@cowprotocol/sdk-config' + +import type { RelayCurrency, RelayFees, RelayQuoteDetails } from './types' + +const RELAY_NATIVE_ADDRESS = '0x0000000000000000000000000000000000000000' + +export function computeSlippageBps(details: RelayQuoteDetails): number { + // Primary path: use pre-computed slippage from Relay response + const destPercent = details.slippageTolerance?.destination?.percent + if (destPercent != null) { + return Math.round(parseFloat(destPercent) * 100) + } + + // Fallback: compute from USD values + const inUsd = Number(details.currencyIn.amountUsd) + const outUsd = Number(details.currencyOut.amountUsd) + + if (inUsd <= 0) return 0 + + const slippage = 1 - outUsd / inUsd + return Math.max(0, Math.trunc(slippage * 10_000)) +} + +export function computeFeeBps(details: RelayQuoteDetails, fees: RelayFees): number { + const inUsd = Number(details.currencyIn.amountUsd) + const feeUsd = Number(fees.relayer.amountUsd) + + if (inUsd <= 0) return 0 + + return Math.trunc((feeUsd / inUsd) * 10_000) +} + +export function computeFeeInBuyCurrency(fees: RelayFees, details: RelayQuoteDetails): bigint { + const inAmount = BigInt(details.currencyIn.amount) + if (inAmount === 0n) return 0n + + const feeAmount = BigInt(fees.relayer.amount) + const buyAmount = BigInt(details.currencyOut.amount) + return (feeAmount * buyAmount) / inAmount +} + +export function mapRelayCurrencyToTokenInfo(currency: RelayCurrency): TokenInfo { + return { + chainId: currency.chainId, + address: currency.address === RELAY_NATIVE_ADDRESS ? ETH_ADDRESS : currency.address, + symbol: currency.symbol, + name: currency.name, + decimals: currency.decimals, + } +} + +/** + * Convert CoW SDK ETH_ADDRESS to Relay native address format. + * Relay uses 0x0000...0000 for native tokens, CoW uses 0xEeee...EEeE. + */ +export function toRelayAddress(address: string): string { + return address.toLowerCase() === ETH_ADDRESS.toLowerCase() ? RELAY_NATIVE_ADDRESS : address +} + +/** + * Convert Relay native address to CoW SDK ETH_ADDRESS format. + */ +export function fromRelayAddress(address: string): string { + return address.toLowerCase() === RELAY_NATIVE_ADDRESS ? ETH_ADDRESS : address +} diff --git a/src/bridging/providers/relay/relay-logo.png b/src/bridging/providers/relay/relay-logo.png new file mode 100644 index 000000000..6cc6279b2 Binary files /dev/null and b/src/bridging/providers/relay/relay-logo.png differ