diff --git a/packages/sdk-router/src/module/synapseModuleSet.ts b/packages/sdk-router/src/module/synapseModuleSet.ts index 723b7998ee..c78d8aaa37 100644 --- a/packages/sdk-router/src/module/synapseModuleSet.ts +++ b/packages/sdk-router/src/module/synapseModuleSet.ts @@ -6,6 +6,7 @@ import { BigintIsh } from '../constants' import { BridgeQuote, BridgeRoute, FeeConfig } from './types' import { SynapseModule } from './synapseModule' import { applyOptionalDeadline } from '../utils/deadlines' +import { isSameAddress } from '../utils/addressUtils' import { Query } from './query' export abstract class SynapseModuleSet { @@ -70,10 +71,7 @@ export abstract class SynapseModuleSet { moduleAddress: string ): SynapseModule | undefined { const module = this.getModule(chainId) - if (module?.address.toLowerCase() === moduleAddress.toLowerCase()) { - return module - } - return undefined + return isSameAddress(module?.address, moduleAddress) ? module : undefined } /** diff --git a/packages/sdk-router/src/rfq/api.integration.test.ts b/packages/sdk-router/src/rfq/api.integration.test.ts index a041d98b98..3b4e31dc5f 100644 --- a/packages/sdk-router/src/rfq/api.integration.test.ts +++ b/packages/sdk-router/src/rfq/api.integration.test.ts @@ -1,14 +1,80 @@ -import { getAllQuotes } from './api' +import { parseFixed } from '@ethersproject/bignumber' + +import { getAllQuotes, getBestRelayerQuote, RelayerQuote } from './api' +import { Ticker } from './ticker' +import { ETH_NATIVE_TOKEN_ADDRESS } from '../utils/handleNativeToken' global.fetch = require('node-fetch') // Retry the flaky tests up to 3 times jest.retryTimes(3) -describe('getAllQuotes', () => { - it('Integration test', async () => { +describe('Integration test: getAllQuotes', () => { + it('returns a non-empty array', async () => { const result = await getAllQuotes() // console.log('Current quotes: ' + JSON.stringify(result, null, 2)) expect(result.length).toBeGreaterThan(0) }) }) + +describe('Integration test: getBestRelayerQuote', () => { + const ticker: Ticker = { + originToken: { + chainId: 42161, + token: ETH_NATIVE_TOKEN_ADDRESS, + }, + destToken: { + chainId: 10, + token: ETH_NATIVE_TOKEN_ADDRESS, + }, + } + const userAddress = '0x0000000000000000000000000000000000007331' + + describe('Cases where a non-zero quote is returned', () => { + it('ARB ETH -> OP ETH; 0.01 ETH', async () => { + const result = await getBestRelayerQuote( + ticker, + parseFixed('0.01', 18), + userAddress + ) + expect(result?.destAmount.gt(0)).toBe(true) + expect(result?.relayerAddress).toBeDefined() + }) + }) + + describe('Cases where a zero quote is returned', () => { + const quoteZero: RelayerQuote = { + destAmount: parseFixed('0'), + } + + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => { + // Do nothing + }) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('ARB ETH -> OP ETH; 1337 wei', async () => { + const result = await getBestRelayerQuote( + ticker, + parseFixed('1337'), + userAddress + ) + expect(result).toEqual(quoteZero) + expect(console.error).toHaveBeenCalled() + }) + + it('ARB ETH -> OP ETH; 10**36 wei', async () => { + const result = await getBestRelayerQuote( + ticker, + parseFixed('1', 36), + userAddress + ) + expect(result).toEqual(quoteZero) + expect(console.error).toHaveBeenCalled() + }) + }) +}) diff --git a/packages/sdk-router/src/rfq/api.test.ts b/packages/sdk-router/src/rfq/api.test.ts index 7960f6f140..45b323b370 100644 --- a/packages/sdk-router/src/rfq/api.test.ts +++ b/packages/sdk-router/src/rfq/api.test.ts @@ -1,18 +1,23 @@ import fetchMock from 'jest-fetch-mock' +import { parseFixed } from '@ethersproject/bignumber' -import { getAllQuotes } from './api' +import { + getAllQuotes, + getBestRelayerQuote, + PutRFQResponseAPI, + RelayerQuote, +} from './api' +import { Ticker } from './ticker' import { FastBridgeQuoteAPI, unmarshallFastBridgeQuote } from './quote' const OK_RESPONSE_TIME = 1900 const SLOW_RESPONSE_TIME = 2100 const delayedAPIPromise = ( - quotes: FastBridgeQuoteAPI[], + body: string, delay: number ): Promise<{ body: string }> => { - return new Promise((resolve) => - setTimeout(() => resolve({ body: JSON.stringify(quotes) }), delay) - ) + return new Promise((resolve) => setTimeout(() => resolve({ body }), delay)) } describe('getAllQuotes', () => { @@ -65,7 +70,7 @@ describe('getAllQuotes', () => { it('when the response takes a long, but reasonable time to return', async () => { fetchMock.mockResponseOnce(() => - delayedAPIPromise(quotesAPI, OK_RESPONSE_TIME) + delayedAPIPromise(JSON.stringify(quotesAPI), OK_RESPONSE_TIME) ) const result = await getAllQuotes() expect(result).toEqual([ @@ -102,7 +107,7 @@ describe('getAllQuotes', () => { it('when the response takes too long to return', async () => { fetchMock.mockResponseOnce(() => - delayedAPIPromise(quotesAPI, SLOW_RESPONSE_TIME) + delayedAPIPromise(JSON.stringify(quotesAPI), SLOW_RESPONSE_TIME) ) const result = await getAllQuotes() expect(result).toEqual([]) @@ -110,3 +115,147 @@ describe('getAllQuotes', () => { }) }) }) + +describe('getBestRelayerQuote', () => { + const bigAmount = parseFixed('1', 24) + const bigAmountStr = '1000000000000000000000000' + const relayerAddress = '0x0000000000000000000000000000000000001337' + const quoteID = 'acbdef-123456' + const userAddress = '0x0000000000000000000000000000000000007331' + + const ticker: Ticker = { + originToken: { + chainId: 1, + token: '0x0000000000000000000000000000000000000001', + }, + destToken: { + chainId: 2, + token: '0x0000000000000000000000000000000000000002', + }, + } + + const noQuotesFound: PutRFQResponseAPI = { + success: false, + reason: 'No quotes found', + } + + const quoteFound: PutRFQResponseAPI = { + success: true, + quote_id: quoteID, + dest_amount: bigAmountStr, + relayer_address: relayerAddress, + } + + const quote: RelayerQuote = { + destAmount: bigAmount, + relayerAddress, + quoteID, + } + + const quoteZero: RelayerQuote = { + destAmount: parseFixed('0'), + } + + beforeEach(() => { + fetchMock.enableMocks() + }) + + afterEach(() => { + fetchMock.resetMocks() + }) + + describe('Returns a non-zero quote', () => { + it('when the response is ok', async () => { + fetchMock.mockResponseOnce(JSON.stringify(quoteFound)) + const result = await getBestRelayerQuote(ticker, bigAmount, userAddress) + expect(result).toEqual(quote) + }) + + it('when the response takes a long, but reasonable time to return', async () => { + fetchMock.mockResponseOnce(() => + delayedAPIPromise(JSON.stringify(quoteFound), OK_RESPONSE_TIME) + ) + const result = await getBestRelayerQuote(ticker, bigAmount, userAddress) + expect(result).toEqual(quote) + }) + + it('when the user address is not provided', async () => { + fetchMock.mockResponseOnce(JSON.stringify(quoteFound)) + const result = await getBestRelayerQuote(ticker, bigAmount) + expect(result).toEqual(quote) + }) + + it('when the response does not contain quote ID', async () => { + const responseWithoutID = { ...quoteFound, quote_id: undefined } + const quoteWithoutID = { ...quote, quoteID: undefined } + fetchMock.mockResponseOnce(JSON.stringify(responseWithoutID)) + const result = await getBestRelayerQuote(ticker, bigAmount, userAddress) + expect(result).toEqual(quoteWithoutID) + }) + }) + + describe('Returns a zero quote', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => { + // Do nothing + }) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('when the response is not ok', async () => { + fetchMock.mockResponseOnce(JSON.stringify(quoteFound), { status: 500 }) + const result = await getBestRelayerQuote(ticker, bigAmount, userAddress) + expect(result).toEqual(quoteZero) + expect(console.error).toHaveBeenCalled() + }) + + it('when the response success is false', async () => { + fetchMock.mockResponseOnce(JSON.stringify(noQuotesFound)) + const result = await getBestRelayerQuote(ticker, bigAmount, userAddress) + expect(result).toEqual(quoteZero) + expect(console.error).toHaveBeenCalled() + }) + + it('when the response takes too long to return', async () => { + fetchMock.mockResponseOnce(() => + delayedAPIPromise(JSON.stringify(quoteFound), SLOW_RESPONSE_TIME) + ) + const result = await getBestRelayerQuote(ticker, bigAmount, userAddress) + expect(result).toEqual(quoteZero) + expect(console.error).toHaveBeenCalled() + }) + + it('when the response does not contain dest amount', async () => { + const responseWithoutDestAmount = { + ...quoteFound, + dest_amount: undefined, + } + fetchMock.mockResponseOnce(JSON.stringify(responseWithoutDestAmount)) + const result = await getBestRelayerQuote(ticker, bigAmount, userAddress) + expect(result).toEqual(quoteZero) + expect(console.error).toHaveBeenCalled() + }) + + it('when the response does not contain relayer address', async () => { + const responseWithoutRelayerAddress = { + ...quoteFound, + relayer_address: undefined, + } + fetchMock.mockResponseOnce(JSON.stringify(responseWithoutRelayerAddress)) + const result = await getBestRelayerQuote(ticker, bigAmount, userAddress) + expect(result).toEqual(quoteZero) + expect(console.error).toHaveBeenCalled() + }) + + it('when the response dest amount is zero', async () => { + const responseWithZeroDestAmount = { ...quoteFound, dest_amount: '0' } + fetchMock.mockResponseOnce(JSON.stringify(responseWithZeroDestAmount)) + const result = await getBestRelayerQuote(ticker, bigAmount, userAddress) + expect(result).toEqual(quoteZero) + expect(console.error).toHaveBeenCalled() + }) + }) +}) diff --git a/packages/sdk-router/src/rfq/api.ts b/packages/sdk-router/src/rfq/api.ts index 1723ab1683..560924b479 100644 --- a/packages/sdk-router/src/rfq/api.ts +++ b/packages/sdk-router/src/rfq/api.ts @@ -1,3 +1,6 @@ +import { BigNumber } from '@ethersproject/bignumber' + +import { Ticker } from './ticker' import { FastBridgeQuote, FastBridgeQuoteAPI, @@ -7,13 +10,54 @@ import { const API_URL = 'https://rfq-api.omnirpc.io' const API_TIMEOUT = 2000 +/** + * The expiration window for active quotes in milliseconds to be used by the RFQ API. + * Relayers will have to respond with a quote within this time window. + */ +const EXPIRATION_WINDOW = 1000 + +export type PutRFQRequestAPI = { + user_address?: string + // TODO: make integrator_id required + integrator_id?: string + quote_types: string[] + data: { + origin_chain_id: number + dest_chain_id: number + origin_token_addr: string + dest_token_addr: string + origin_amount_exact: string + expiration_window: number + } +} + +export type PutRFQResponseAPI = { + success: boolean + reason?: string + quote_type?: string + quote_id?: string + dest_amount?: string + relayer_address?: string +} + +export type RelayerQuote = { + destAmount: BigNumber + relayerAddress?: string + quoteID?: string +} + +const ZeroQuote: RelayerQuote = { + destAmount: BigNumber.from(0), +} + const fetchWithTimeout = async ( url: string, - timeout: number + timeout: number, + init?: RequestInit ): Promise => { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), timeout) - return fetch(url, { signal: controller.signal }).finally(() => + return fetch(url, { signal: controller.signal, ...init }).finally(() => clearTimeout(timeoutId) ) } @@ -48,3 +92,71 @@ export const getAllQuotes = async (): Promise => { return [] } } + +/** + * Hits Quoter API /rfq PUT endpoint to get the best quote for a given ticker and origin amount. + * + * @returns A promise that resolves to the best quote. + * Will return a zero quote if the request fails or times out. + */ +export const getBestRelayerQuote = async ( + ticker: Ticker, + originAmount: BigNumber, + originUserAddress?: string +): Promise => { + try { + const rfqRequest: PutRFQRequestAPI = { + user_address: originUserAddress, + // TODO: add active quotes once they are fixed + quote_types: ['passive'], + data: { + origin_chain_id: ticker.originToken.chainId, + dest_chain_id: ticker.destToken.chainId, + origin_token_addr: ticker.originToken.token, + dest_token_addr: ticker.destToken.token, + origin_amount_exact: originAmount.toString(), + expiration_window: EXPIRATION_WINDOW, + }, + } + const response = await fetchWithTimeout(`${API_URL}/rfq`, API_TIMEOUT, { + method: 'PUT', + body: JSON.stringify(rfqRequest), + headers: { + 'Content-Type': 'application/json', + }, + }) + if (!response.ok) { + console.error('Error fetching quote:', response.statusText) + return ZeroQuote + } + // Check that response is successful, contains non-zero dest amount, and has a relayer address + const rfqResponse: PutRFQResponseAPI = await response.json() + if (!rfqResponse.success) { + console.error( + 'No RFQ quote returned:', + rfqResponse.reason ?? 'Unknown reason' + ) + return ZeroQuote + } + if (!rfqResponse.dest_amount || !rfqResponse.relayer_address) { + console.error( + 'Error fetching quote: missing dest_amount or relayer_address in response:', + rfqResponse + ) + return ZeroQuote + } + const destAmount = BigNumber.from(rfqResponse.dest_amount) + if (destAmount.lte(0)) { + console.error('No RFQ quote returned') + return ZeroQuote + } + return { + destAmount, + relayerAddress: rfqResponse.relayer_address, + quoteID: rfqResponse.quote_id, + } + } catch (error) { + console.error('Error fetching quote:', error) + return ZeroQuote + } +} diff --git a/packages/sdk-router/src/rfq/fastBridgeRouterSet.ts b/packages/sdk-router/src/rfq/fastBridgeRouterSet.ts index 8caee554ed..3a774d0ac7 100644 --- a/packages/sdk-router/src/rfq/fastBridgeRouterSet.ts +++ b/packages/sdk-router/src/rfq/fastBridgeRouterSet.ts @@ -20,9 +20,9 @@ import { import { FastBridgeRouter } from './fastBridgeRouter' import { ChainProvider } from '../router' import { ONE_HOUR, TEN_MINUTES } from '../utils/deadlines' -import { FastBridgeQuote, applyQuote } from './quote' -import { marshallTicker } from './ticker' -import { getAllQuotes } from './api' +import { isSameAddress } from '../utils/addressUtils' +import { marshallTicker, Ticker } from './ticker' +import { getAllQuotes, getBestRelayerQuote } from './api' export class FastBridgeRouterSet extends SynapseModuleSet { static readonly MAX_QUOTE_AGE_MILLISECONDS = 5 * 60 * 1000 // 5 minutes @@ -100,45 +100,48 @@ export class FastBridgeRouterSet extends SynapseModuleSet { if (!this.getModule(originChainId) || !this.getModule(destChainId)) { return [] } - // Get all quotes that result in the final token - const allQuotes: FastBridgeQuote[] = await this.getQuotes( + // Get all tickers that can be used to fulfill the tokenIn -> tokenOut intent via RFQ + const tickers = await this.getAllTickers( originChainId, destChainId, tokenOut ) - // Get queries for swaps on the origin chain into the "RFQ-supported token" - const filteredQuotes = await this.filterOriginQuotes( + // Get queries for swaps on the origin chain from tokenIn into the "RFQ-supported token" + const filteredTickers = await this.filterTickersWithPossibleSwap( originChainId, tokenIn, amountIn, - allQuotes + tickers ) const protocolFeeRate = await this.getFastBridgeRouter( originChainId ).getProtocolFeeRate() - return filteredQuotes - .map(({ quote, originQuery }) => ({ - quote, + const quotes = await Promise.all( + filteredTickers.map(async ({ ticker, originQuery }) => ({ + ticker, originQuery, - // Apply quote to the proceeds of the origin swap with protocol fee applied - // TODO: handle optional gas airdrop pricing - destAmountOut: applyQuote( - quote, - this.applyProtocolFeeRate(originQuery.minAmountOut, protocolFeeRate) + quote: await getBestRelayerQuote( + ticker, + // Get the quote for the proceeds of the origin swap with protocol fee applied + this.applyProtocolFeeRate(originQuery.minAmountOut, protocolFeeRate), + originUserAddress + // TODO: pass MAX_QUOTE_AGE here once supported by the API ), })) - .filter(({ destAmountOut }) => destAmountOut.gt(0)) - .map(({ quote, originQuery, destAmountOut }) => ({ + ) + return quotes + .filter(({ quote }) => quote.destAmount.gt(0)) + .map(({ ticker, originQuery, quote }) => ({ originChainId, destChainId, bridgeToken: { - symbol: marshallTicker(quote.ticker), - token: quote.ticker.destToken.token, + symbol: marshallTicker(ticker), + token: ticker.destToken.token, }, originQuery, destQuery: FastBridgeRouterSet.createRFQDestQuery( tokenOut, - destAmountOut, + quote.destAmount, originUserAddress ), bridgeModuleName: this.bridgeModuleName, @@ -251,66 +254,76 @@ export class FastBridgeRouterSet extends SynapseModuleSet { } /** - * Filters the list of quotes to only include those that can be used for given amount of input token. - * For every filtered quote, the origin query is returned with the information for tokenIn -> RFQ token swaps. + * Filters the list of tickers to only include those that can be used for given amount of input token. + * For every filtered ticker, the origin query is returned with the information for tokenIn -> ticker swaps. */ - private async filterOriginQuotes( + private async filterTickersWithPossibleSwap( originChainId: number, tokenIn: string, amountIn: BigintIsh, - allQuotes: FastBridgeQuote[] - ): Promise<{ quote: FastBridgeQuote; originQuery: Query }[]> { + tickers: Ticker[] + ): Promise<{ ticker: Ticker; originQuery: Query }[]> { // Get queries for swaps on the origin chain into the "RFQ-supported token" const originQueries = await this.getFastBridgeRouter( originChainId ).getOriginAmountOut( tokenIn, - allQuotes.map((quote) => quote.ticker.originToken.token), + tickers.map((ticker) => ticker.originToken.token), amountIn ) - // Note: allQuotes.length === originQueries.length - // Zip the quotes and queries together, filter out "no path found" queries - return allQuotes - .map((quote, index) => ({ - quote, + // Note: tickers.length === originQueries.length + // Zip the tickers and queries together, filter out "no path found" queries + return tickers + .map((ticker, index) => ({ + ticker, originQuery: originQueries[index], })) .filter(({ originQuery }) => originQuery.minAmountOut.gt(0)) } /** - * Get the list of quotes between two chains for a given final token. + * Get all unique tickers for a given origin chain and a destination token. In other words, + * this is the list of all origin tokens that can be used to create a quote for a + * swap to the given destination token, without duplicates. * * @param originChainId - The ID of the origin chain. * @param destChainId - The ID of the destination chain. * @param tokenOut - The final token of the cross-chain swap. - * @returns A promise that resolves to the list of supported tickers. + * @returns A promise that resolves to the list of tickers. */ - private async getQuotes( + private async getAllTickers( originChainId: number, destChainId: number, tokenOut: string - ): Promise { + ): Promise { const allQuotes = await getAllQuotes() const originFB = await this.getFastBridgeAddress(originChainId) const destFB = await this.getFastBridgeAddress(destChainId) + // First, we filter out quotes for other chainIDs, bridges or destination token. + // Then, we filter out quotes that are too old. + // Finally, we remove the duplicates of the origin token. return allQuotes - .filter( - (quote) => + .filter((quote) => { + const areSameChainsAndToken = quote.ticker.originToken.chainId === originChainId && quote.ticker.destToken.chainId === destChainId && - quote.ticker.destToken.token && - quote.ticker.destToken.token.toLowerCase() === tokenOut.toLowerCase() - ) - .filter( - (quote) => - quote.originFastBridge.toLowerCase() === originFB.toLowerCase() && - quote.destFastBridge.toLowerCase() === destFB.toLowerCase() - ) - .filter((quote) => { + isSameAddress(quote.originFastBridge, originFB) && + isSameAddress(quote.destFastBridge, destFB) && + isSameAddress(quote.ticker.destToken.token, tokenOut) + // TODO: don't filter by age here const age = Date.now() - quote.updatedAt - return 0 <= age && age < FastBridgeRouterSet.MAX_QUOTE_AGE_MILLISECONDS + const isValidAge = + 0 <= age && age < FastBridgeRouterSet.MAX_QUOTE_AGE_MILLISECONDS + return areSameChainsAndToken && isValidAge }) + .map((quote) => quote.ticker) + .filter( + (ticker, index, self) => + index === + self.findIndex((t) => + isSameAddress(t.originToken.token, ticker.originToken.token) + ) + ) } public static createRFQDestQuery( diff --git a/packages/sdk-router/src/rfq/quote.test.ts b/packages/sdk-router/src/rfq/quote.test.ts index 253df3da5a..07f3a0872c 100644 --- a/packages/sdk-router/src/rfq/quote.test.ts +++ b/packages/sdk-router/src/rfq/quote.test.ts @@ -1,143 +1,12 @@ -import { BigNumber, parseFixed } from '@ethersproject/bignumber' +import { BigNumber } from '@ethersproject/bignumber' import { FastBridgeQuote, FastBridgeQuoteAPI, marshallFastBridgeQuote, unmarshallFastBridgeQuote, - applyQuote, } from './quote' -const createZeroAmountTests = (quote: FastBridgeQuote) => { - describe('Returns zero', () => { - it('If origin amount is zero', () => { - expect(applyQuote(quote, BigNumber.from(0))).toEqual(BigNumber.from(0)) - }) - - it('If origin amount is lower than fixed fee', () => { - expect(applyQuote(quote, quote.fixedFee.sub(1))).toEqual( - BigNumber.from(0) - ) - }) - - it('If origin amount is equal to fixed fee', () => { - expect(applyQuote(quote, quote.fixedFee)).toEqual(BigNumber.from(0)) - }) - - it('If origin amount is greater than max origin amount + fixed fee', () => { - const amount = quote.maxOriginAmount.add(quote.fixedFee).add(1) - expect(applyQuote(quote, amount)).toEqual(BigNumber.from(0)) - }) - }) - - describe('Returns non-zero', () => { - it('If origin amount is equal to max origin amount', () => { - expect(applyQuote(quote, quote.maxOriginAmount)).not.toEqual( - BigNumber.from(0) - ) - }) - - it('If origin amount is 1 wei greater than max origin amount', () => { - const amount = quote.maxOriginAmount.add(1) - expect(applyQuote(quote, amount)).not.toEqual(BigNumber.from(0)) - }) - - it('If origin amount is max origin amount + fixed fee', () => { - const amount = quote.maxOriginAmount.add(quote.fixedFee) - expect(applyQuote(quote, amount)).not.toEqual(BigNumber.from(0)) - }) - }) -} - -const createCorrectAmountTest = ( - quote: FastBridgeQuote, - amount: BigNumber, - expected: BigNumber -) => { - it(`${amount.toString()} -> ${expected.toString()}`, () => { - expect(applyQuote(quote, amount)).toEqual(expected) - }) -} - -const createQuoteTests = ( - quoteTemplate: FastBridgeQuote, - originDecimals: number, - destDecimals: number -) => { - describe(`Origin decimals: ${originDecimals}, dest decimals: ${destDecimals}`, () => { - describe(`origin:destination price 1:1`, () => { - const quote: FastBridgeQuote = { - ...quoteTemplate, - maxOriginAmount: parseFixed('100000', originDecimals), - destAmount: parseFixed('100000', destDecimals), - fixedFee: parseFixed('1', originDecimals), - } - - // 10 origin -> 9 dest - createCorrectAmountTest( - quote, - parseFixed('10', originDecimals), - parseFixed('9', destDecimals) - ) - createZeroAmountTests(quote) - }) - - describe(`origin:destination price 1:1.0001`, () => { - const quote: FastBridgeQuote = { - ...quoteTemplate, - maxOriginAmount: parseFixed('100000', originDecimals), - destAmount: parseFixed('100010', destDecimals), - fixedFee: parseFixed('1', originDecimals), - } - - // 10 origin -> 9.0009 dest - createCorrectAmountTest( - quote, - parseFixed('10', originDecimals), - parseFixed('9.0009', destDecimals) - ) - createZeroAmountTests(quote) - }) - - describe(`origin:destination price 1:0.9999`, () => { - const quote: FastBridgeQuote = { - ...quoteTemplate, - maxOriginAmount: parseFixed('100000', originDecimals), - destAmount: parseFixed('99990', destDecimals), - fixedFee: parseFixed('1', originDecimals), - } - - // 10 origin -> 8.9991 dest - createCorrectAmountTest( - quote, - parseFixed('10', originDecimals), - parseFixed('8.9991', destDecimals) - ) - createZeroAmountTests(quote) - }) - }) -} - -const createRoundDownTest = ( - quoteTemplate: FastBridgeQuote, - maxOriginAmount: BigNumber, - destAmount: BigNumber, - fixedFee: BigNumber, - amountIn: BigNumber, - expected: BigNumber -) => { - describe(`Rounds down with price ${maxOriginAmount.toString()} -> ${destAmount.toString()} and fixed fee ${fixedFee.toString()}`, () => { - const quote: FastBridgeQuote = { - ...quoteTemplate, - maxOriginAmount, - destAmount, - fixedFee, - } - - createCorrectAmountTest(quote, amountIn, expected) - }) -} - describe('quote', () => { const quoteAPI: FastBridgeQuoteAPI = { origin_chain_id: 1, @@ -180,70 +49,4 @@ describe('quote', () => { it('should marshall a quote', () => { expect(marshallFastBridgeQuote(quote)).toEqual(quoteAPI) }) - - describe('applyQuote', () => { - // Equal decimals - createQuoteTests(quote, 18, 18) - createRoundDownTest( - quote, - parseFixed('1234', 18), - parseFixed('2345', 18), - parseFixed('1', 18), - parseFixed('2', 18), - // (2 - 1) * 2345 / 1234 = 1.900324149108589951 - BigNumber.from('1900324149108589951') - ) - - // // Bigger decimals - createQuoteTests(quote, 6, 18) - createRoundDownTest( - quote, - parseFixed('1234', 6), - parseFixed('2345', 18), - parseFixed('1', 6), - parseFixed('2', 6), - // (2 - 1) * 2345 / 1234 = 1.900324149108589951 - BigNumber.from('1900324149108589951') - ) - - // Smaller decimals - createQuoteTests(quote, 18, 6) - createRoundDownTest( - quote, - parseFixed('1234', 18), - parseFixed('2345', 6), - parseFixed('1', 18), - parseFixed('2', 18), - // (2 - 1) * 2345 / 1234 = 1.900324149108589951 - BigNumber.from('1900324') - ) - - it('Returns zero when max origin amount is zero', () => { - const zeroQuote: FastBridgeQuote = { - ...quote, - maxOriginAmount: BigNumber.from(0), - } - const amount = zeroQuote.fixedFee.mul(2) - expect(applyQuote(zeroQuote, amount)).toEqual(BigNumber.from(0)) - }) - - it('Returns zero when dest amount is zero', () => { - const zeroQuote: FastBridgeQuote = { - ...quote, - destAmount: BigNumber.from(0), - } - const amount = zeroQuote.fixedFee.mul(2) - expect(applyQuote(zeroQuote, amount)).toEqual(BigNumber.from(0)) - }) - - it('Returns zero when max origin amount and dest amount are zero', () => { - const zeroQuote: FastBridgeQuote = { - ...quote, - maxOriginAmount: BigNumber.from(0), - destAmount: BigNumber.from(0), - } - const amount = zeroQuote.fixedFee.mul(2) - expect(applyQuote(zeroQuote, amount)).toEqual(BigNumber.from(0)) - }) - }) }) diff --git a/packages/sdk-router/src/rfq/quote.ts b/packages/sdk-router/src/rfq/quote.ts index 5d8816d1f7..714c9bdb29 100644 --- a/packages/sdk-router/src/rfq/quote.ts +++ b/packages/sdk-router/src/rfq/quote.ts @@ -1,5 +1,4 @@ import { BigNumber } from 'ethers' -import { Zero } from '@ethersproject/constants' import { Ticker } from './ticker' @@ -69,21 +68,3 @@ export const marshallFastBridgeQuote = ( updated_at: new Date(quote.updatedAt).toISOString(), } } - -export const applyQuote = ( - quote: FastBridgeQuote, - originAmount: BigNumber -): BigNumber => { - // Check that the origin amount covers the fixed fee - if (originAmount.lte(quote.fixedFee)) { - return Zero - } - // Check that the Relayer is able to process the origin amount (post fixed fee) - const amountAfterFee = originAmount.sub(quote.fixedFee) - if (amountAfterFee.gt(quote.maxOriginAmount)) { - return Zero - } - // After these checks: 0 < amountAfterFee <= quote.maxOriginAmount - // Solve (amountAfterFee -> ?) using (maxOriginAmount -> destAmount) pricing ratio - return amountAfterFee.mul(quote.destAmount).div(quote.maxOriginAmount) -} diff --git a/packages/sdk-router/src/utils/addressUtils.test.ts b/packages/sdk-router/src/utils/addressUtils.test.ts new file mode 100644 index 0000000000..5ceff95d5b --- /dev/null +++ b/packages/sdk-router/src/utils/addressUtils.test.ts @@ -0,0 +1,114 @@ +import { isSameAddress } from './addressUtils' + +describe('isSameAddress', () => { + const lowerCaseAlice = '0x0123456789abcdef0123456789abcdef01234567' + const checkSumdAlice = '0x0123456789abcDEF0123456789abCDef01234567' + const upperCaseAlice = '0x0123456789ABCDEF0123456789ABCDEF01234567' + + const lowerCaseBob = '0x0123456789abcdef0123456789abcdef01234568' + const checkSumdBob = '0x0123456789ABCDeF0123456789aBcdEF01234568' + const upperCaseBob = '0x0123456789ABCDEF0123456789ABCDEF01234568' + + describe('True when the addresses are the same', () => { + it('Both lowercase', () => { + expect(isSameAddress(lowerCaseAlice, lowerCaseAlice)).toBe(true) + expect(isSameAddress(lowerCaseBob, lowerCaseBob)).toBe(true) + }) + + it('Both checksummed', () => { + expect(isSameAddress(checkSumdAlice, checkSumdAlice)).toBe(true) + expect(isSameAddress(checkSumdBob, checkSumdBob)).toBe(true) + }) + + it('Both uppercase', () => { + expect(isSameAddress(upperCaseAlice, upperCaseAlice)).toBe(true) + expect(isSameAddress(upperCaseBob, upperCaseBob)).toBe(true) + }) + + it('Lowercase and checksummed', () => { + expect(isSameAddress(lowerCaseAlice, checkSumdAlice)).toBe(true) + expect(isSameAddress(checkSumdAlice, lowerCaseAlice)).toBe(true) + expect(isSameAddress(lowerCaseBob, checkSumdBob)).toBe(true) + expect(isSameAddress(checkSumdBob, lowerCaseBob)).toBe(true) + }) + + it('Lowercase and uppercase', () => { + expect(isSameAddress(lowerCaseAlice, upperCaseAlice)).toBe(true) + expect(isSameAddress(upperCaseAlice, lowerCaseAlice)).toBe(true) + expect(isSameAddress(lowerCaseBob, upperCaseBob)).toBe(true) + expect(isSameAddress(upperCaseBob, lowerCaseBob)).toBe(true) + }) + + it('Checksummed and uppercase', () => { + expect(isSameAddress(checkSumdAlice, upperCaseAlice)).toBe(true) + expect(isSameAddress(upperCaseAlice, checkSumdAlice)).toBe(true) + expect(isSameAddress(checkSumdBob, upperCaseBob)).toBe(true) + expect(isSameAddress(upperCaseBob, checkSumdBob)).toBe(true) + }) + }) + + describe('False when the addresses are different', () => { + it('Both lowercase', () => { + expect(isSameAddress(lowerCaseAlice, lowerCaseBob)).toBe(false) + expect(isSameAddress(lowerCaseBob, lowerCaseAlice)).toBe(false) + }) + + it('Both checksummed', () => { + expect(isSameAddress(checkSumdAlice, checkSumdBob)).toBe(false) + expect(isSameAddress(checkSumdBob, checkSumdAlice)).toBe(false) + }) + + it('Both uppercase', () => { + expect(isSameAddress(upperCaseAlice, upperCaseBob)).toBe(false) + expect(isSameAddress(upperCaseBob, upperCaseAlice)).toBe(false) + }) + + it('Lowercase and checksummed', () => { + expect(isSameAddress(lowerCaseAlice, checkSumdBob)).toBe(false) + expect(isSameAddress(checkSumdBob, lowerCaseAlice)).toBe(false) + }) + + it('Lowercase and uppercase', () => { + expect(isSameAddress(lowerCaseAlice, upperCaseBob)).toBe(false) + expect(isSameAddress(upperCaseBob, lowerCaseAlice)).toBe(false) + }) + + it('Checksummed and uppercase', () => { + expect(isSameAddress(checkSumdAlice, upperCaseBob)).toBe(false) + expect(isSameAddress(upperCaseBob, checkSumdAlice)).toBe(false) + }) + }) + + describe('False when one of the addresses is undefined', () => { + it('single undefined', () => { + expect(isSameAddress(undefined, lowerCaseAlice)).toBe(false) + expect(isSameAddress(lowerCaseAlice, undefined)).toBe(false) + }) + + it('both undefined', () => { + expect(isSameAddress(undefined, undefined)).toBe(false) + }) + }) + + describe('False when one of the addresses is empty', () => { + it('single empty', () => { + expect(isSameAddress('', lowerCaseAlice)).toBe(false) + expect(isSameAddress(lowerCaseAlice, '')).toBe(false) + }) + + it('both empty', () => { + expect(isSameAddress('', '')).toBe(false) + }) + }) + + describe('False when one of the addresses is null', () => { + it('single null', () => { + expect(isSameAddress(null as any, lowerCaseAlice)).toBe(false) + expect(isSameAddress(lowerCaseAlice, null as any)).toBe(false) + }) + + it('both null', () => { + expect(isSameAddress(null as any, null as any)).toBe(false) + }) + }) +}) diff --git a/packages/sdk-router/src/utils/addressUtils.ts b/packages/sdk-router/src/utils/addressUtils.ts new file mode 100644 index 0000000000..0856ab32c5 --- /dev/null +++ b/packages/sdk-router/src/utils/addressUtils.ts @@ -0,0 +1,3 @@ +export const isSameAddress = (addr1?: string, addr2?: string): boolean => { + return !!addr1 && !!addr2 && addr1.toLowerCase() === addr2.toLowerCase() +} diff --git a/packages/sdk-router/src/utils/handleNativeToken.ts b/packages/sdk-router/src/utils/handleNativeToken.ts index 4a4eec3407..1c093d36d7 100644 --- a/packages/sdk-router/src/utils/handleNativeToken.ts +++ b/packages/sdk-router/src/utils/handleNativeToken.ts @@ -2,6 +2,8 @@ import { AddressZero, Zero } from '@ethersproject/constants' import { BigNumber } from '@ethersproject/bignumber' import { PopulatedTransaction } from '@ethersproject/contracts' +import { isSameAddress } from './addressUtils' + export const ETH_NATIVE_TOKEN_ADDRESS = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' @@ -12,7 +14,7 @@ export const handleNativeToken = (tokenAddr: string) => { } export const isNativeToken = (tokenAddr: string): boolean => { - return tokenAddr.toLowerCase() === ETH_NATIVE_TOKEN_ADDRESS.toLowerCase() + return isSameAddress(tokenAddr, ETH_NATIVE_TOKEN_ADDRESS) } /** diff --git a/packages/sdk-router/src/utils/logs.ts b/packages/sdk-router/src/utils/logs.ts index 2b8c2f1e4e..cc264a3798 100644 --- a/packages/sdk-router/src/utils/logs.ts +++ b/packages/sdk-router/src/utils/logs.ts @@ -2,6 +2,8 @@ import { Log, Provider } from '@ethersproject/abstract-provider' import { Contract } from '@ethersproject/contracts' import { Interface } from '@ethersproject/abi' +import { isSameAddress } from './addressUtils' + /** * Extracts the first log from a transaction receipt that matches * the provided contract and any of the provided event names. @@ -26,7 +28,10 @@ export const getMatchingTxLog = async ( const topics = getEventTopics(contract.interface, eventNames) // Find the log with the correct contract address and topic matching any of the provided topics const matchingLog = txReceipt.logs.find((log) => { - return log.address === contract.address && topics.includes(log.topics[0]) + return ( + isSameAddress(log.address, contract.address) && + topics.includes(log.topics[0]) + ) }) if (!matchingLog) { // Throw an error and include the event names in the message