diff --git a/packages/sdk-router/package.json b/packages/sdk-router/package.json index 4ac74732e1..9aa11caec3 100644 --- a/packages/sdk-router/package.json +++ b/packages/sdk-router/package.json @@ -47,6 +47,8 @@ "@types/jest": "^24.0.25", "dotenv": "^16.3.1", "husky": "^8.0.1", + "jest-mock-extended": "^3.0.5", + "node-fetch": "^2.0.0", "size-limit": "^8.1.0", "tsdx": "^0.14.1", "tslib": "^2.4.0", diff --git a/packages/sdk-router/src/abi/FastBridge.json b/packages/sdk-router/src/abi/FastBridge.json index 2b008da14e..6481a4bd5f 100644 --- a/packages/sdk-router/src/abi/FastBridge.json +++ b/packages/sdk-router/src/abi/FastBridge.json @@ -534,6 +534,7 @@ { "components": [ { "internalType": "uint32", "name": "dstChainId", "type": "uint32" }, + { "internalType": "address", "name": "sender", "type": "address" }, { "internalType": "address", "name": "to", "type": "address" }, { "internalType": "address", diff --git a/packages/sdk-router/src/constants/addresses.ts b/packages/sdk-router/src/constants/addresses.ts index 37679ac770..e79cdc9f9f 100644 --- a/packages/sdk-router/src/constants/addresses.ts +++ b/packages/sdk-router/src/constants/addresses.ts @@ -1,4 +1,9 @@ -import { CCTP_SUPPORTED_CHAIN_IDS, SUPPORTED_CHAIN_IDS } from './chainIds' +import { + CCTP_SUPPORTED_CHAIN_IDS, + RFQ_SUPPORTED_CHAIN_IDS, + SUPPORTED_CHAIN_IDS, + SupportedChainId, +} from './chainIds' export type AddressMap = { [chainId: number]: string @@ -48,3 +53,20 @@ export const CCTP_ROUTER_ADDRESS_MAP: AddressMap = generateAddressMap( CCTP_ROUTER_ADDRESS, CCTP_ROUTER_EXCEPTION_MAP ) + +/** + * FastBridge contract address for all chains except ones from FAST_BRIDGE_ADDRESS. + * + * TODO: Update this address once FastBridge is deployed. + */ +const FAST_BRIDGE_ADDRESS = '' +const FAST_BRIDGE_EXCEPTION_MAP: AddressMap = { + [SupportedChainId.OPTIMISM]: '0x743fFbd0DbF88F6fCB7FaDf58fB641da93056EdF', + [SupportedChainId.ARBITRUM]: '0xA9EBFCb6DCD416FE975D5aB862717B329407f4F7', +} + +export const FAST_BRIDGE_ADDRESS_MAP: AddressMap = generateAddressMap( + RFQ_SUPPORTED_CHAIN_IDS, + FAST_BRIDGE_ADDRESS, + FAST_BRIDGE_EXCEPTION_MAP +) diff --git a/packages/sdk-router/src/constants/chainIds.ts b/packages/sdk-router/src/constants/chainIds.ts index 35fce47b9d..9896f83870 100644 --- a/packages/sdk-router/src/constants/chainIds.ts +++ b/packages/sdk-router/src/constants/chainIds.ts @@ -41,3 +41,13 @@ export const CCTP_SUPPORTED_CHAIN_IDS: number[] = [ SupportedChainId.BASE, SupportedChainId.POLYGON, // Circle domain 7 ] + +/** + * List of chain ids where FastBridge (RFQ) is deployed, ordered by chain id + * + * Note: This is a subset of SUPPORTED_CHAIN_IDS. + */ +export const RFQ_SUPPORTED_CHAIN_IDS: number[] = [ + SupportedChainId.OPTIMISM, + SupportedChainId.ARBITRUM, +] diff --git a/packages/sdk-router/src/constants/medianTime.ts b/packages/sdk-router/src/constants/medianTime.ts index fde1ce699f..401fc06917 100644 --- a/packages/sdk-router/src/constants/medianTime.ts +++ b/packages/sdk-router/src/constants/medianTime.ts @@ -38,3 +38,13 @@ export const MEDIAN_TIME_CCTP = { [SupportedChainId.AVALANCHE]: 30, [SupportedChainId.POLYGON]: 480, } + +/** + * Median time (in seconds) for a SynapseRFQ transaction to be completed, + * when the transaction is sent from a given chain. + * TODO: Update this value once we have a better estimate. + */ +export const MEDIAN_TIME_RFQ = { + [SupportedChainId.OPTIMISM]: 30, + [SupportedChainId.ARBITRUM]: 30, +} diff --git a/packages/sdk-router/src/module/query.test.ts b/packages/sdk-router/src/module/query.test.ts index 811986a44e..d67bff0c6e 100644 --- a/packages/sdk-router/src/module/query.test.ts +++ b/packages/sdk-router/src/module/query.test.ts @@ -10,6 +10,7 @@ import { modifyDeadline, applySlippage, applySlippageInBips, + createNoSwapQuery, } from './query' describe('#query', () => { @@ -380,4 +381,15 @@ describe('#query', () => { ) }) }) + + it('createNoSwapQuery', () => { + const query = createNoSwapQuery('1', BigNumber.from(2)) + expect(query).toEqual({ + routerAdapter: '0x0000000000000000000000000000000000000000', + tokenOut: '1', + minAmountOut: BigNumber.from(2), + deadline: BigNumber.from(0), + rawParams: '0x', + }) + }) }) diff --git a/packages/sdk-router/src/module/query.ts b/packages/sdk-router/src/module/query.ts index 02b2476f45..686135f79d 100644 --- a/packages/sdk-router/src/module/query.ts +++ b/packages/sdk-router/src/module/query.ts @@ -165,3 +165,20 @@ export const applySlippageInBips = ( ): Query => { return applySlippage(query, slipBasisPoints, 10000) } + +/** + * Creates a Query object for a no-swap bridge action. + * + * @param token - The token to bridge. + * @param amount - The amount of token to bridge. + * @returns The Query object for a no-swap bridge action. + */ +export const createNoSwapQuery = (token: string, amount: BigNumber): Query => { + return { + routerAdapter: AddressZero, + tokenOut: token, + minAmountOut: amount, + deadline: BigNumber.from(0), + rawParams: '0x', + } +} diff --git a/packages/sdk-router/src/rfq/api.test.ts b/packages/sdk-router/src/rfq/api.test.ts new file mode 100644 index 0000000000..f290b3bff3 --- /dev/null +++ b/packages/sdk-router/src/rfq/api.test.ts @@ -0,0 +1,69 @@ +import { getAllQuotes } from './api' +import { FastBridgeQuoteAPI, unmarshallFastBridgeQuote } from './quote' + +describe('getAllQuotes', () => { + const quotesAPI: FastBridgeQuoteAPI[] = [ + { + origin_chain_id: 1, + origin_token_addr: '0x0000000000000000000000000000000000000001', + dest_chain_id: 2, + dest_token_addr: '0x0000000000000000000000000000000000000002', + dest_amount: '3', + max_origin_amount: '4', + fixed_fee: '5', + origin_fast_bridge_address: '10', + dest_fast_bridge_address: '11', + relayer_addr: '0x0000000000000000000000000000000000000003', + updated_at: '2023-01-01T00:00:00.420Z', + }, + { + origin_chain_id: 3, + origin_token_addr: '0x0000000000000000000000000000000000000004', + dest_chain_id: 4, + dest_token_addr: '0x0000000000000000000000000000000000000005', + dest_amount: '6', + max_origin_amount: '7', + fixed_fee: '8', + origin_fast_bridge_address: '20', + dest_fast_bridge_address: '21', + relayer_addr: '0x0000000000000000000000000000000000000006', + updated_at: '2023-01-02T00:00:00.420Z', + }, + ] + + it('returns an empty array when the response is not ok', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + status: 500, + ok: false, + }) + ) as any + + const result = await getAllQuotes() + expect(result).toEqual([]) + }) + + it('returns a list of quotes when the response is ok', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + status: 200, + ok: true, + json: () => Promise.resolve(quotesAPI), + }) + ) as any + + const result = await getAllQuotes() + // You might need to adjust this depending on how your unmarshallFastBridgeQuote function works + expect(result).toEqual([ + unmarshallFastBridgeQuote(quotesAPI[0]), + unmarshallFastBridgeQuote(quotesAPI[1]), + ]) + }) + + it('Integration test', async () => { + global.fetch = require('node-fetch') + const result = await getAllQuotes() + console.log('Quotes: ' + JSON.stringify(result, null, 2)) + expect(result.length).toBeGreaterThan(0) + }) +}) diff --git a/packages/sdk-router/src/rfq/api.ts b/packages/sdk-router/src/rfq/api.ts new file mode 100644 index 0000000000..d69b7ee14c --- /dev/null +++ b/packages/sdk-router/src/rfq/api.ts @@ -0,0 +1,23 @@ +import { + FastBridgeQuote, + FastBridgeQuoteAPI, + unmarshallFastBridgeQuote, +} from './quote' + +const API_URL = 'https://rfq-api.omnirpc.io' + +/** + * Hits Quoter API /quotes endpoint to get all quotes. + * + * @returns A promise that resolves to the list of quotes. + */ +export const getAllQuotes = async (): Promise => { + const response = await fetch(API_URL + '/quotes') + // Return empty list if response is not ok + if (!response.ok) { + return [] + } + // The response is a list of quotes in the FastBridgeQuoteAPI format + const quotes: FastBridgeQuoteAPI[] = await response.json() + return quotes.map(unmarshallFastBridgeQuote) +} diff --git a/packages/sdk-router/src/rfq/fastBridge.test.ts b/packages/sdk-router/src/rfq/fastBridge.test.ts new file mode 100644 index 0000000000..899b6b48f6 --- /dev/null +++ b/packages/sdk-router/src/rfq/fastBridge.test.ts @@ -0,0 +1,250 @@ +import { mock } from 'jest-mock-extended' +import { BigNumber, providers } from 'ethers' +import { Log, TransactionReceipt } from '@ethersproject/abstract-provider' + +import { BridgeParams, FastBridge } from './fastBridge' +import { FAST_BRIDGE_ADDRESS_MAP, SupportedChainId } from '../constants' +import { NATIVE_ADDRESS } from '../constants/testValues' +import { Query } from '../module' + +jest.mock('@ethersproject/contracts', () => { + return { + Contract: jest.fn().mockImplementation((...args: any[]) => { + const actualContract = jest.requireActual('@ethersproject/contracts') + const actualInstance = new actualContract.Contract(...args) + return { + address: args[0], + interface: args[1], + bridgeRelays: jest.fn(), + populateTransaction: { + bridge: actualInstance.populateTransaction.bridge, + }, + } + }), + } +}) + +const expectCorrectPopulatedTx = ( + populatedTx: any, + expectedAddress: string, + expectedBridgeParams: BridgeParams +) => { + expect(populatedTx).toBeDefined() + expect(populatedTx.to).toEqual(expectedAddress) + expect(populatedTx.data).toEqual( + FastBridge.fastBridgeInterface.encodeFunctionData('bridge', [ + expectedBridgeParams, + ]) + ) + if ( + expectedBridgeParams.originToken.toLowerCase() === + NATIVE_ADDRESS.toLowerCase() + ) { + expect(populatedTx.value).toEqual( + BigNumber.from(expectedBridgeParams.originAmount) + ) + } else { + expect(populatedTx.value).toEqual(BigNumber.from(0)) + } +} + +const createBridgeTests = ( + fastBridge: FastBridge, + expectedBridgeParams: BridgeParams, + originQuery: Query, + destQuery: Query +) => { + it('bridge without sendChainGas', async () => { + const populatedTx = await fastBridge.bridge( + expectedBridgeParams.to, + SupportedChainId.OPTIMISM, + expectedBridgeParams.originToken, + BigNumber.from(expectedBridgeParams.originAmount), + originQuery, + destQuery + ) + expectCorrectPopulatedTx( + populatedTx, + FAST_BRIDGE_ADDRESS_MAP[SupportedChainId.ARBITRUM], + expectedBridgeParams + ) + }) + + it.skip('bridge with sendChainGas', async () => { + const bridgeParamsWithGas = { + ...expectedBridgeParams, + sendChainGas: true, + } + // TODO: adjust this test once sendChainGas is supported + const destQueryWithGas = { + ...destQuery, + rawParams: '0x0', + } + const populatedTx = await fastBridge.bridge( + expectedBridgeParams.to, + SupportedChainId.OPTIMISM, + expectedBridgeParams.originToken, + BigNumber.from(expectedBridgeParams.originAmount), + originQuery, + destQueryWithGas + ) + expectCorrectPopulatedTx( + populatedTx, + FAST_BRIDGE_ADDRESS_MAP[SupportedChainId.ARBITRUM], + bridgeParamsWithGas + ) + }) +} + +describe('FastBridge', () => { + const mockProvider = mock() + + const fastBridge = new FastBridge( + SupportedChainId.ARBITRUM, + mockProvider, + FAST_BRIDGE_ADDRESS_MAP[SupportedChainId.ARBITRUM] + ) + + const mockedTxHash = '0x1234' + const mockedSynapseTxId = '0x4321' + + describe('getSynapseTxId', () => { + const bridgeRequestedTopic = + '0x2a8233b619c9d479346e133f609855c0a94d89fbcfa62f846a9f0cfdd1198ccf' + const mockedOriginLog = { + address: fastBridge.address, + // keccak256('BridgeRequested(bytes32,address,bytes)') + topics: [bridgeRequestedTopic], + } as Log + const mockedUnrelatedLog = { + address: fastBridge.address, + topics: ['0x0'], + } as Log + const mockedReceipt = { + logs: [mockedUnrelatedLog, mockedOriginLog], + } as TransactionReceipt + + it('should return the Synapse transaction ID', async () => { + // Return the mocked receipt only for the mocked transaction hash + mockProvider.getTransactionReceipt.mockImplementation((txHash) => { + if (txHash === mockedTxHash) { + return Promise.resolve(mockedReceipt) + } else { + return Promise.resolve(undefined as any) + } + }) + // Return the mocked Synapse transaction ID for the mocked origin log + fastBridge['fastBridgeContract'].interface.parseLog = jest.fn( + (log: { topics: Array; data: string }) => ({ + args: { + transactionId: + log.topics[0] === bridgeRequestedTopic ? mockedSynapseTxId : '', + }, + }) + ) as any + const result = await fastBridge.getSynapseTxId(mockedTxHash) + expect(result).toEqual(mockedSynapseTxId) + }) + }) + + describe('getBridgeTxStatus', () => { + it('returns false when bridgeRelays returns false', async () => { + // Returns false only when mockedSynapseTxId is passed + jest + .spyOn(fastBridge['fastBridgeContract'], 'bridgeRelays') + .mockImplementation((synapseTxId) => + Promise.resolve(synapseTxId !== mockedSynapseTxId) + ) + const result = await fastBridge.getBridgeTxStatus(mockedSynapseTxId) + expect(result).toEqual(false) + }) + + it('returns true when bridgeRelays returns true', async () => { + // Returns true only when mockedSynapseTxId is passed + jest + .spyOn(fastBridge['fastBridgeContract'], 'bridgeRelays') + .mockImplementation((synapseTxId) => + Promise.resolve(synapseTxId === mockedSynapseTxId) + ) + const result = await fastBridge.getBridgeTxStatus(mockedSynapseTxId) + expect(result).toEqual(true) + }) + }) + + describe('bridge', () => { + const expectedBridgeParamsFragment = { + dstChainId: SupportedChainId.OPTIMISM, + sender: '0x0000000000000000000000000000000000000001', + to: '0x0000000000000000000000000000000000000001', + originAmount: 1234, + destAmount: 5678, + deadline: 9999, + } + + const originQueryFragment = { + routerAdapter: '0x0000000000000000000000000000000000000000', + minAmountOut: BigNumber.from(expectedBridgeParamsFragment.originAmount), + deadline: BigNumber.from(8888), + rawParams: '0x', + } + + const destQueryFragment = { + routerAdapter: '0x0000000000000000000000000000000000000000', + minAmountOut: BigNumber.from(expectedBridgeParamsFragment.destAmount), + deadline: BigNumber.from(expectedBridgeParamsFragment.deadline), + rawParams: '0x', + } + + describe('bridge ERC20 token', () => { + const expectedBridgeParams: BridgeParams = { + ...expectedBridgeParamsFragment, + originToken: '0x000000000000000000000000000000000000000A', + destToken: '0x000000000000000000000000000000000000000b', + sendChainGas: false, + } + + const originQuery: Query = { + ...originQueryFragment, + tokenOut: expectedBridgeParams.originToken, + } + + const destQuery: Query = { + ...destQueryFragment, + tokenOut: expectedBridgeParams.destToken, + } + + createBridgeTests( + fastBridge, + expectedBridgeParams, + originQuery, + destQuery + ) + }) + + describe('bridge native token', () => { + const expectedBridgeParams: BridgeParams = { + ...expectedBridgeParamsFragment, + originToken: NATIVE_ADDRESS, + destToken: NATIVE_ADDRESS, + sendChainGas: false, + } + + const originQuery: Query = { + ...originQueryFragment, + tokenOut: expectedBridgeParams.originToken, + } + + const destQuery: Query = { + ...destQueryFragment, + tokenOut: expectedBridgeParams.destToken, + } + + createBridgeTests( + fastBridge, + expectedBridgeParams, + originQuery, + destQuery + ) + }) + }) +}) diff --git a/packages/sdk-router/src/rfq/fastBridge.ts b/packages/sdk-router/src/rfq/fastBridge.ts new file mode 100644 index 0000000000..a6af1c6b81 --- /dev/null +++ b/packages/sdk-router/src/rfq/fastBridge.ts @@ -0,0 +1,112 @@ +import { Provider } from '@ethersproject/abstract-provider' +import invariant from 'tiny-invariant' +import { Contract, PopulatedTransaction } from '@ethersproject/contracts' +import { Interface } from '@ethersproject/abi' +import { BigNumber } from '@ethersproject/bignumber' + +import fastBridgeAbi from '../abi/FastBridge.json' +import { + FastBridge as FastBridgeContract, + IFastBridge, +} from '../typechain/FastBridge' +import { BigintIsh } from '../constants' +import { SynapseModule, Query } from '../module' +import { getMatchingTxLog } from '../utils/logs' +import { adjustValueIfNative } from '../utils/handleNativeToken' + +// Define type alias +export type BridgeParams = IFastBridge.BridgeParamsStruct + +export class FastBridge implements SynapseModule { + static fastBridgeInterface = new Interface(fastBridgeAbi) + + public readonly address: string + public readonly chainId: number + public readonly provider: Provider + + private readonly fastBridgeContract: FastBridgeContract + + // All possible events emitted by the FastBridge contract in the origin transaction (in alphabetical order) + private readonly originEvents = ['BridgeRequested'] + + constructor(chainId: number, provider: Provider, address: string) { + invariant(chainId, 'CHAIN_ID_UNDEFINED') + invariant(provider, 'PROVIDER_UNDEFINED') + invariant(address, 'ADDRESS_UNDEFINED') + invariant(FastBridge.fastBridgeInterface, 'INTERFACE_UNDEFINED') + this.chainId = chainId + this.provider = provider + this.address = address + this.fastBridgeContract = new Contract( + address, + FastBridge.fastBridgeInterface, + provider + ) as FastBridgeContract + } + + /** + * @inheritdoc SynapseModule.bridge + */ + public async bridge( + to: string, + destChainId: number, + token: string, + amount: BigintIsh, + originQuery: Query, + destQuery: Query + ): Promise { + // TODO: remove this once swaps on origin are supported + invariant( + BigNumber.from(amount).eq(originQuery.minAmountOut), + 'AMOUNT_MISMATCH' + ) + invariant( + token.toLowerCase() === originQuery.tokenOut.toLowerCase(), + 'TOKEN_MISMATCH' + ) + // TODO: encode sendChainGas into destQuery.rawParams + const bridgeParams: BridgeParams = { + dstChainId: destChainId, + // TODO: remove this once Router for RFQ is deployed (origin address will be derived there on-chain) + // SDK doesn't really have access to the msg.sender of the origin transaction, so we use the recipient address + sender: to, + to, + originToken: token, + destToken: destQuery.tokenOut, + originAmount: amount, + destAmount: destQuery.minAmountOut, + sendChainGas: false, + deadline: destQuery.deadline, + } + const populatedTransaction = + await this.fastBridgeContract.populateTransaction.bridge(bridgeParams) + // Adjust the tx.value if the token is native + return adjustValueIfNative( + populatedTransaction, + token, + BigNumber.from(amount) + ) + } + + /** + * @inheritdoc SynapseModule.getSynapseTxId + */ + public async getSynapseTxId(txHash: string): Promise { + const fastBridgeLog = await getMatchingTxLog( + this.provider, + txHash, + this.fastBridgeContract, + this.originEvents + ) + // transactionId always exists in the log as we are using the correct ABI + const parsedLog = this.fastBridgeContract.interface.parseLog(fastBridgeLog) + return parsedLog.args.transactionId + } + + /** + * @inheritdoc SynapseModule.getBridgeTxStatus + */ + public async getBridgeTxStatus(synapseTxId: string): Promise { + return this.fastBridgeContract.bridgeRelays(synapseTxId) + } +} diff --git a/packages/sdk-router/src/rfq/fastBridgeSet.test.ts b/packages/sdk-router/src/rfq/fastBridgeSet.test.ts new file mode 100644 index 0000000000..7852d5aa75 --- /dev/null +++ b/packages/sdk-router/src/rfq/fastBridgeSet.test.ts @@ -0,0 +1,415 @@ +import { providers } from 'ethers' +import { BigNumber, parseFixed } from '@ethersproject/bignumber' + +import { getTestProviderUrl } from '../constants/testValues' +import { SupportedChainId, FAST_BRIDGE_ADDRESS_MAP } from '../constants' +import { FastBridgeSet } from './fastBridgeSet' +import { FastBridgeQuoteAPI } from './quote' +import { ChainToken } from './ticker' + +type Pricing = { + originAmount: number + fixedFee: number + destAmount: number + originDecimals: number + destDecimals: number +} + +const createQuoteTokenFragment = ( + originToken: ChainToken, + destToken: ChainToken +): { + origin_chain_id: number + origin_token_addr: string + dest_chain_id: number + dest_token_addr: string + origin_fast_bridge_address: string + dest_fast_bridge_address: string +} => { + return { + origin_chain_id: originToken.chainId, + origin_token_addr: originToken.token, + dest_chain_id: destToken.chainId, + dest_token_addr: destToken.token, + origin_fast_bridge_address: FAST_BRIDGE_ADDRESS_MAP[originToken.chainId], + dest_fast_bridge_address: FAST_BRIDGE_ADDRESS_MAP[destToken.chainId], + } +} + +const createQuotePricingFragment = ( + price: Pricing +): { + max_origin_amount: string + fixed_fee: string + dest_amount: string +} => { + return { + max_origin_amount: parseFixed( + price.originAmount.toString(), + price.originDecimals + ).toString(), + fixed_fee: parseFixed( + price.fixedFee.toString(), + price.originDecimals + ).toString(), + dest_amount: parseFixed( + price.destAmount.toString(), + price.destDecimals + ).toString(), + } +} + +const createBridgeRouteTest = ( + fastBridgeSet: FastBridgeSet, + originToken: ChainToken, + destToken: ChainToken, + originAmount: BigNumber, + expectedDestAmounts: BigNumber[] +) => { + it(`Should return ${expectedDestAmounts.length} routes for amount=${originAmount}`, async () => { + const routes = await fastBridgeSet.getBridgeRoutes( + originToken.chainId, + destToken.chainId, + originToken.token, + destToken.token, + originAmount + ) + expect(routes.length).toEqual(expectedDestAmounts.length) + routes.forEach((route, index) => { + expect(route.destQuery.minAmountOut).toEqual(expectedDestAmounts[index]) + }) + }) +} + +const createBridgeRoutesTests = ( + fastBridgeSet: FastBridgeSet, + originDecimals: number, + destDecimals: number +) => { + const tokenA = '0x000000000000000000000000000000000000000A' + const tokenB = '0x000000000000000000000000000000000000000b' + + const arbA: ChainToken = { + chainId: SupportedChainId.ARBITRUM, + token: tokenA, + } + const arbB: ChainToken = { + chainId: SupportedChainId.ARBITRUM, + token: tokenB, + } + const opA: ChainToken = { + chainId: SupportedChainId.OPTIMISM, + token: tokenA, + } + const opB: ChainToken = { + chainId: SupportedChainId.OPTIMISM, + token: tokenB, + } + + const price1: Pricing = { + originAmount: 10000, + fixedFee: 10, + destAmount: 10000, + originDecimals, + destDecimals, + } + + // Better price with higher fixed fee and lower liquidity + const price2: Pricing = { + originAmount: 1000, + fixedFee: 100, + destAmount: 2000, + originDecimals, + destDecimals, + } + + // Use following combinations of tokens and prices: + // - ARB_A -> OP_A: [] + // - ARB_A -> OP_B: [price1] + // - ARB_B -> OP_A: [price2] + // - ARB_B -> OP_B: [price1, price2] + const mockedQuotesAPI: FastBridgeQuoteAPI[] = [ + { + ...createQuoteTokenFragment(arbA, opB), + ...createQuotePricingFragment(price1), + relayer_addr: '0x0', + updated_at: '2021-01-01T00:00:00.000Z', + }, + { + ...createQuoteTokenFragment(arbB, opA), + ...createQuotePricingFragment(price2), + relayer_addr: '0x0', + updated_at: '2021-01-01T00:00:00.000Z', + }, + { + ...createQuoteTokenFragment(arbB, opB), + ...createQuotePricingFragment(price1), + relayer_addr: '0x0', + updated_at: '2021-01-01T00:00:00.000Z', + }, + { + ...createQuoteTokenFragment(arbB, opB), + ...createQuotePricingFragment(price2), + relayer_addr: '0x0', + updated_at: '2021-01-01T00:00:00.000Z', + }, + ] + + beforeEach(() => { + global.fetch = jest.fn(() => + Promise.resolve({ + status: 200, + ok: true, + json: () => Promise.resolve(mockedQuotesAPI), + }) + ) as any + // Use UpdatedAt + 1 minute as the current time + Date.now = jest.fn(() => Date.parse('2021-01-01T00:01:00.000Z')) + }) + + describe('arbA -> opA [no routes]', () => { + createBridgeRouteTest( + fastBridgeSet, + arbA, + opA, + parseFixed('20', originDecimals), + [] + ) + + createBridgeRouteTest( + fastBridgeSet, + arbA, + opA, + parseFixed('500', originDecimals), + [] + ) + + createBridgeRouteTest( + fastBridgeSet, + arbA, + opA, + parseFixed('10011', originDecimals), + [] + ) + }) + + describe('arbA -> opB [(1:1, 10 fee, up to 10k)]', () => { + createBridgeRouteTest( + fastBridgeSet, + arbA, + opB, + parseFixed('10', originDecimals), + [] + ) + + createBridgeRouteTest( + fastBridgeSet, + arbA, + opB, + parseFixed('500', originDecimals), + [parseFixed('490', destDecimals)] + ) + + // Higher than available liquidity + createBridgeRouteTest( + fastBridgeSet, + arbA, + opB, + parseFixed('10011', originDecimals), + [] + ) + }) + + describe('arbB -> opA [(1:2, 100 fee, up to 1k)]', () => { + createBridgeRouteTest( + fastBridgeSet, + arbB, + opA, + parseFixed('100', originDecimals), + [] + ) + + createBridgeRouteTest( + fastBridgeSet, + arbB, + opA, + parseFixed('500', originDecimals), + [parseFixed('800', destDecimals)] + ) + + // Higher than available liquidity + createBridgeRouteTest( + fastBridgeSet, + arbB, + opA, + parseFixed('1101', originDecimals), + [] + ) + }) + + describe('arbB -> opB [(1:1, 10 fee, up to 10k), (1:2, 100 fee, up to 1k)]', () => { + createBridgeRouteTest( + fastBridgeSet, + arbB, + opB, + parseFixed('10', originDecimals), + [] + ) + + createBridgeRouteTest( + fastBridgeSet, + arbB, + opB, + parseFixed('100', originDecimals), + [parseFixed('90', destDecimals)] + ) + + createBridgeRouteTest( + fastBridgeSet, + arbB, + opB, + parseFixed('500', originDecimals), + [parseFixed('490', destDecimals), parseFixed('800', destDecimals)] + ) + + // Higher than available liquidity for second price + createBridgeRouteTest( + fastBridgeSet, + arbB, + opB, + parseFixed('1101', originDecimals), + [parseFixed('1091', destDecimals)] + ) + + // Higher than available liquidity for both prices + createBridgeRouteTest( + fastBridgeSet, + arbB, + opB, + parseFixed('11011', originDecimals), + [] + ) + }) + + describe('timestamps', () => { + afterEach(() => { + // Use UpdatedAt + 1 minute as the current time + Date.now = jest.fn(() => Date.parse('2021-01-01T00:01:00.000Z')) + }) + + it('ignores quotes with negative age', async () => { + Date.now = jest.fn(() => Date.parse('2021-01-01T00:00:00.000Z') - 1) + // arbB -> opB should have two quotes for 500 by default + // But we expect zero quotes because the quotes are outdated + const routes = await fastBridgeSet.getBridgeRoutes( + arbB.chainId, + opB.chainId, + arbB.token, + opB.token, + parseFixed('500', originDecimals) + ) + expect(routes.length).toEqual(0) + }) + + it('ignores quotes with age of 5 minutes', async () => { + Date.now = jest.fn(() => Date.parse('2021-01-01T00:05:00.000Z')) + // arbB -> opB should have two quotes for 500 by default + // But we expect zero quotes because the quotes are outdated + const routes = await fastBridgeSet.getBridgeRoutes( + arbB.chainId, + opB.chainId, + arbB.token, + opB.token, + parseFixed('500', originDecimals) + ) + expect(routes.length).toEqual(0) + }) + + it('includes quotes with age of 5 minutes - 1 millisecond', async () => { + Date.now = jest.fn(() => Date.parse('2021-01-01T00:04:59.999Z')) + // arbB -> opB should have two quotes for 500 by default + // We expect two quotes because the quotes are not outdated + const routes = await fastBridgeSet.getBridgeRoutes( + arbB.chainId, + opB.chainId, + arbB.token, + opB.token, + parseFixed('500', originDecimals) + ) + expect(routes.length).toEqual(2) + }) + }) +} + +describe('FastBridgeSet', () => { + const chainIds = [ + SupportedChainId.ARBITRUM, + SupportedChainId.OPTIMISM, + SupportedChainId.DOGECHAIN, + ] + const fastBridgeSet = new FastBridgeSet( + chainIds.map((chainId) => ({ + chainId, + provider: new providers.JsonRpcProvider(getTestProviderUrl(chainId)), + })) + ) + + describe('getModule', () => { + it('Returns correct module', () => { + const module = fastBridgeSet.getModule(SupportedChainId.ARBITRUM) + expect(module).toBeDefined() + expect(module?.address).toEqual( + FAST_BRIDGE_ADDRESS_MAP[SupportedChainId.ARBITRUM] + ) + }) + + it('Returns undefined for chain without module', () => { + const module = fastBridgeSet.getModule(SupportedChainId.DOGECHAIN) + expect(module).toBeUndefined() + }) + + it('Returns undefined for undefined chain', () => { + const module = fastBridgeSet.getModule(SupportedChainId.BSC) + expect(module).toBeUndefined() + }) + }) + + describe('getEstimatedTime', () => { + it('Returns correct estimated time', () => { + const estimatedTime = fastBridgeSet.getEstimatedTime( + SupportedChainId.ARBITRUM + ) + expect(estimatedTime).toEqual(30) + }) + + it('Throws error for chain without estimated time', () => { + expect(() => + fastBridgeSet.getEstimatedTime(SupportedChainId.DOGECHAIN) + ).toThrow() + }) + + it('Throws error for undefined chain', () => { + expect(() => + fastBridgeSet.getEstimatedTime(SupportedChainId.BSC) + ).toThrow() + }) + }) + + describe('getBridgeRoutes', () => { + describe('6:6 decimals', () => { + createBridgeRoutesTests(fastBridgeSet, 6, 6) + }) + + describe('18:18 decimals', () => { + createBridgeRoutesTests(fastBridgeSet, 18, 18) + }) + + describe('6:18 decimals', () => { + createBridgeRoutesTests(fastBridgeSet, 6, 18) + }) + + describe('18:6 decimals', () => { + createBridgeRoutesTests(fastBridgeSet, 18, 6) + }) + }) +}) diff --git a/packages/sdk-router/src/rfq/fastBridgeSet.ts b/packages/sdk-router/src/rfq/fastBridgeSet.ts new file mode 100644 index 0000000000..c24dc9ed0d --- /dev/null +++ b/packages/sdk-router/src/rfq/fastBridgeSet.ts @@ -0,0 +1,200 @@ +import { Provider } from '@ethersproject/abstract-provider' +import { BigNumber } from '@ethersproject/bignumber' +import invariant from 'tiny-invariant' + +import { + BigintIsh, + FAST_BRIDGE_ADDRESS_MAP, + MEDIAN_TIME_RFQ, +} from '../constants' +import { + BridgeRoute, + FeeConfig, + SynapseModule, + SynapseModuleSet, + Query, + createNoSwapQuery, +} from '../module' +import { ChainProvider } from '../router' +import { FastBridge } from './fastBridge' +import { marshallTicker } from './ticker' +import { FastBridgeQuote, applyQuote } from './quote' +import { getAllQuotes } from './api' +import { ONE_HOUR, TEN_MINUTES } from '../utils/deadlines' + +export class FastBridgeSet extends SynapseModuleSet { + static readonly MAX_QUOTE_AGE_MILLISECONDS = 5 * 60 * 1000 // 5 minutes + + public readonly bridgeModuleName = 'SynapseRFQ' + public readonly allEvents = ['BridgeRequestedEvent', 'BridgeRelayedEvent'] + + public fastBridges: { + [chainId: number]: FastBridge + } + public providers: { + [chainId: number]: Provider + } + + constructor(chains: ChainProvider[]) { + super() + this.fastBridges = {} + this.providers = {} + chains.forEach(({ chainId, provider }) => { + const address = FAST_BRIDGE_ADDRESS_MAP[chainId] + // Skip chains without a FastBridge address + if (address) { + this.fastBridges[chainId] = new FastBridge(chainId, provider, address) + this.providers[chainId] = provider + } + }) + } + + /** + * @inheritdoc SynapseModuleSet.getModule + */ + public getModule(chainId: number): SynapseModule | undefined { + return this.fastBridges[chainId] + } + + /** + * @inheritdoc RouterSet.getOriginAmountOut + */ + public getEstimatedTime(chainId: number): number { + const medianTime = MEDIAN_TIME_RFQ[chainId as keyof typeof MEDIAN_TIME_RFQ] + invariant(medianTime, `No estimated time for chain ${chainId}`) + return medianTime + } + + /** + * @inheritdoc SynapseModuleSet.getBridgeRoutes + */ + public async getBridgeRoutes( + originChainId: number, + destChainId: number, + tokenIn: string, + tokenOut: string, + amountIn: BigintIsh + ): Promise { + // Get all quotes that result in the final token + const allQuotes: FastBridgeQuote[] = await this.getQuotes( + originChainId, + destChainId, + tokenOut + ) + // Get queries for swaps on the origin chain into the "RFQ-supported token" + const filteredQuotes = await this.filterOriginQuotes( + originChainId, + tokenIn, + amountIn, + allQuotes + ) + return filteredQuotes + .map(({ quote, originQuery }) => ({ + quote, + originQuery, + // Apply quote to the proceeds of the origin swap + destAmountOut: applyQuote(quote, originQuery.minAmountOut), + })) + .filter(({ destAmountOut }) => destAmountOut.gt(0)) + .map(({ quote, originQuery, destAmountOut }) => ({ + originChainId, + destChainId, + bridgeToken: { + symbol: marshallTicker(quote.ticker), + token: quote.ticker.destToken.token, + }, + originQuery, + // On-chain swaps are not supported for RFQ tokens + destQuery: createNoSwapQuery(tokenOut, destAmountOut), + bridgeModuleName: this.bridgeModuleName, + })) + } + + /** + * @inheritdoc SynapseModuleSet.getFeeData + */ + async getFeeData(): Promise<{ + feeAmount: BigNumber + feeConfig: FeeConfig + }> { + // TODO: figure out if we need to report anything here + return { + feeAmount: BigNumber.from(0), + feeConfig: { + bridgeFee: 0, + minFee: BigNumber.from(0), + maxFee: BigNumber.from(0), + }, + } + } + + /** + * @inheritdoc SynapseModuleSet.getDefaultPeriods + */ + getDefaultPeriods(): { + originPeriod: number + destPeriod: number + } { + return { + originPeriod: TEN_MINUTES, + destPeriod: ONE_HOUR, + } + } + + /** + * 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. + */ + private async filterOriginQuotes( + originChainId: number, + tokenIn: string, + amountIn: BigintIsh, + allQuotes: FastBridgeQuote[] + ): Promise<{ quote: FastBridgeQuote; originQuery: Query }[]> { + // TODO: change this to "find best path" once swaps on the origin chain are supported + invariant(originChainId, 'Origin chain ID is required') + return allQuotes + .filter( + (quote) => + quote.ticker.originToken.token.toLowerCase() === tokenIn.toLowerCase() + ) + .filter((quote) => { + const age = Date.now() - quote.updatedAt + return 0 <= age && age < FastBridgeSet.MAX_QUOTE_AGE_MILLISECONDS + }) + .map((quote) => ({ + quote, + originQuery: createNoSwapQuery(tokenIn, BigNumber.from(amountIn)), + })) + } + + /** + * Get the list of quotes between two chains for a given final token. + * + * @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. + */ + private async getQuotes( + originChainId: number, + destChainId: number, + tokenOut: string + ): Promise { + const allQuotes = await getAllQuotes() + return allQuotes + .filter( + (quote) => + quote.ticker.originToken.chainId === originChainId && + quote.ticker.destToken.chainId === destChainId && + quote.ticker.destToken.token.toLowerCase() === tokenOut.toLowerCase() + ) + .filter( + (quote) => + quote.originFastBridge.toLowerCase() === + FAST_BRIDGE_ADDRESS_MAP[originChainId].toLowerCase() && + quote.destFastBridge.toLowerCase() === + FAST_BRIDGE_ADDRESS_MAP[destChainId].toLowerCase() + ) + } +} diff --git a/packages/sdk-router/src/rfq/index.ts b/packages/sdk-router/src/rfq/index.ts new file mode 100644 index 0000000000..93f74b5e76 --- /dev/null +++ b/packages/sdk-router/src/rfq/index.ts @@ -0,0 +1,3 @@ +export * from './fastBridge' +export * from './fastBridgeSet' +export * from './ticker' diff --git a/packages/sdk-router/src/rfq/quote.test.ts b/packages/sdk-router/src/rfq/quote.test.ts new file mode 100644 index 0000000000..253df3da5a --- /dev/null +++ b/packages/sdk-router/src/rfq/quote.test.ts @@ -0,0 +1,249 @@ +import { BigNumber, parseFixed } 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, + origin_token_addr: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + dest_chain_id: 2, + dest_token_addr: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + dest_amount: '4000000000000000000000', + max_origin_amount: '3000000000000000000000', + fixed_fee: '1000000000000000000', + origin_fast_bridge_address: '0x1', + dest_fast_bridge_address: '0x2', + relayer_addr: '0xB300efF6B57AA09e5fCcf7221FCB9E676A74d931', + updated_at: '2023-01-02T03:04:05.678Z', + } + + const quote: FastBridgeQuote = { + ticker: { + originToken: { + chainId: 1, + token: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + }, + destToken: { + chainId: 2, + token: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + }, + }, + destAmount: BigNumber.from(10).pow(18).mul(4000), + maxOriginAmount: BigNumber.from(10).pow(18).mul(3000), + fixedFee: BigNumber.from(10).pow(18), + originFastBridge: '0x1', + destFastBridge: '0x2', + relayerAddr: '0xB300efF6B57AA09e5fCcf7221FCB9E676A74d931', + updatedAt: 1672628645678, + } + + it('should unmarshall a quote', () => { + expect(unmarshallFastBridgeQuote(quoteAPI)).toEqual(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 new file mode 100644 index 0000000000..5d8816d1f7 --- /dev/null +++ b/packages/sdk-router/src/rfq/quote.ts @@ -0,0 +1,89 @@ +import { BigNumber } from 'ethers' +import { Zero } from '@ethersproject/constants' + +import { Ticker } from './ticker' + +export type FastBridgeQuote = { + ticker: Ticker + destAmount: BigNumber + maxOriginAmount: BigNumber + fixedFee: BigNumber + originFastBridge: string + destFastBridge: string + relayerAddr: string + updatedAt: number +} + +export type FastBridgeQuoteAPI = { + origin_chain_id: number + origin_token_addr: string + dest_chain_id: number + dest_token_addr: string + dest_amount: string + max_origin_amount: string + fixed_fee: string + origin_fast_bridge_address: string + dest_fast_bridge_address: string + relayer_addr: string + updated_at: string +} + +export const unmarshallFastBridgeQuote = ( + quote: FastBridgeQuoteAPI +): FastBridgeQuote => { + return { + ticker: { + originToken: { + chainId: quote.origin_chain_id, + token: quote.origin_token_addr, + }, + destToken: { + chainId: quote.dest_chain_id, + token: quote.dest_token_addr, + }, + }, + destAmount: BigNumber.from(quote.dest_amount), + maxOriginAmount: BigNumber.from(quote.max_origin_amount), + fixedFee: BigNumber.from(quote.fixed_fee), + originFastBridge: quote.origin_fast_bridge_address, + destFastBridge: quote.dest_fast_bridge_address, + relayerAddr: quote.relayer_addr, + updatedAt: Date.parse(quote.updated_at), + } +} + +export const marshallFastBridgeQuote = ( + quote: FastBridgeQuote +): FastBridgeQuoteAPI => { + return { + origin_chain_id: quote.ticker.originToken.chainId, + origin_token_addr: quote.ticker.originToken.token, + dest_chain_id: quote.ticker.destToken.chainId, + dest_token_addr: quote.ticker.destToken.token, + dest_amount: quote.destAmount.toString(), + max_origin_amount: quote.maxOriginAmount.toString(), + fixed_fee: quote.fixedFee.toString(), + origin_fast_bridge_address: quote.originFastBridge, + dest_fast_bridge_address: quote.destFastBridge, + relayer_addr: quote.relayerAddr, + 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/rfq/ticker.test.ts b/packages/sdk-router/src/rfq/ticker.test.ts new file mode 100644 index 0000000000..4d4a1caa1c --- /dev/null +++ b/packages/sdk-router/src/rfq/ticker.test.ts @@ -0,0 +1,169 @@ +import { + ChainToken, + Ticker, + marshallChainToken, + marshallTicker, + unmarshallChainToken, + unmarshallTicker, +} from './ticker' + +describe('ticker operations', () => { + const arbUSDC: ChainToken = { + chainId: 42161, + token: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + } + const arbUSDCStr = '42161:0xaf88d065e77c8cC2239327C5EDb3A432268e5831' + + const ethUSDC: ChainToken = { + chainId: 1, + token: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + } + const ethUSDCStr = '1:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' + + const noSeparator = arbUSDCStr.replace(':', '') + const twoSeparators = + arbUSDCStr + ':0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' + const invalidChainId = 'abc:0xaf88d065e77c8cC2239327C5EDb3A432268e5831' + const invalidAddress = '42161:abc' + + describe('ChainToken', () => { + it('marshalls a ChainToken', () => { + const marshalled = marshallChainToken(arbUSDC) + expect(marshalled).toEqual(arbUSDCStr) + }) + + it('unmarshalls a checksummed ChainToken', () => { + const unmarshalled = unmarshallChainToken(arbUSDCStr) + expect(unmarshalled).toEqual(arbUSDC) + }) + + it('unmarshalls a non-checksummed ChainToken', () => { + const unmarshalled = unmarshallChainToken(arbUSDCStr.toLowerCase()) + expect(unmarshalled).toEqual(arbUSDC) + }) + + describe('Throws during unmarshalling', () => { + it('No token separator is found', () => { + expect(() => unmarshallChainToken(noSeparator)).toThrow( + `Can not unmarshall "${noSeparator}": invalid format` + ) + }) + + it('More than one token separator is found', () => { + expect(() => unmarshallChainToken(twoSeparators)).toThrow( + `Can not unmarshall "${twoSeparators}": invalid format` + ) + }) + + it('Chain ID is not a number', () => { + expect(() => unmarshallChainToken(invalidChainId)).toThrow( + `Can not unmarshall "${invalidChainId}": abc is not a chain ID` + ) + }) + + it('Token is not a valid address', () => { + expect(() => unmarshallChainToken(invalidAddress)).toThrow( + 'invalid address' + ) + }) + }) + }) + + describe('Ticker', () => { + const ticker: Ticker = { + originToken: arbUSDC, + destToken: ethUSDC, + } + const tickerStr = `${arbUSDCStr}-${ethUSDCStr}` + + it('marshalls a Ticker', () => { + const marshalled = marshallTicker(ticker) + expect(marshalled).toEqual(tickerStr) + }) + + it('unmarshalls a Ticker', () => { + const unmarshalled = unmarshallTicker(tickerStr) + expect(unmarshalled).toEqual(ticker) + }) + + it('unmarshalls a Ticker with non-checksummed addresses', () => { + const unmarshalled = unmarshallTicker(tickerStr.toLowerCase()) + expect(unmarshalled).toEqual(ticker) + }) + + describe('Throws during unmarshalling', () => { + describe('Invalid ticker format', () => { + const noTickerSeparator = tickerStr.replace('-', '') + const twoTickerSeparators = tickerStr + `-10:${ethUSDCStr}` + + it('No ticker separator is found', () => { + expect(() => unmarshallTicker(noTickerSeparator)).toThrow( + `Can not unmarshall "${noTickerSeparator}": invalid format` + ) + }) + + it('More than one ticker separator is found', () => { + expect(() => unmarshallTicker(twoTickerSeparators)).toThrow( + `Can not unmarshall "${twoTickerSeparators}": invalid format` + ) + }) + }) + + describe('Invalid origin token', () => { + it('No origin token separator is found', () => { + expect(() => + unmarshallTicker(`${noSeparator}-${ethUSDCStr}`) + ).toThrow(`Can not unmarshall "${noSeparator}": invalid format`) + }) + + it('More than one origin token separator is found', () => { + expect(() => + unmarshallTicker(`${twoSeparators}-${ethUSDCStr}`) + ).toThrow(`Can not unmarshall "${twoSeparators}": invalid format`) + }) + + it('Origin chainId is not a number', () => { + expect(() => + unmarshallTicker(`${invalidChainId}-${ethUSDCStr}`) + ).toThrow( + `Can not unmarshall "${invalidChainId}": abc is not a chain ID` + ) + }) + + it('Origin token is not a valid address', () => { + expect(() => + unmarshallTicker(`${invalidAddress}-${ethUSDCStr}`) + ).toThrow('invalid address') + }) + }) + + describe('Invalid destination token', () => { + it('No destination token separator is found', () => { + expect(() => + unmarshallTicker(`${arbUSDCStr}-${noSeparator}`) + ).toThrow(`Can not unmarshall "${noSeparator}": invalid format`) + }) + + it('More than one destination token separator is found', () => { + expect(() => + unmarshallTicker(`${arbUSDCStr}-${twoSeparators}`) + ).toThrow(`Can not unmarshall "${twoSeparators}": invalid format`) + }) + + it('Destination chainId is not a number', () => { + expect(() => + unmarshallTicker(`${arbUSDCStr}-${invalidChainId}`) + ).toThrow( + `Can not unmarshall "${invalidChainId}": abc is not a chain ID` + ) + }) + + it('Destination token is not a valid address', () => { + expect(() => + unmarshallTicker(`${arbUSDCStr}-${invalidAddress}`) + ).toThrow('invalid address') + }) + }) + }) + }) +}) diff --git a/packages/sdk-router/src/rfq/ticker.ts b/packages/sdk-router/src/rfq/ticker.ts new file mode 100644 index 0000000000..d76a3f3d27 --- /dev/null +++ b/packages/sdk-router/src/rfq/ticker.ts @@ -0,0 +1,79 @@ +import { getAddress } from '@ethersproject/address' + +export type ChainToken = { + chainId: number + token: string +} + +export type Ticker = { + originToken: ChainToken + destToken: ChainToken +} + +/** + * Marshalls a ChainToken object into a string. Follows the format of "chainId:token". + * + * @param chainToken - The ChainToken object to marshall. + * @returns The marshalled string. + */ +export const marshallChainToken = (chainToken: ChainToken): string => { + return `${chainToken.chainId}:${chainToken.token}` +} + +/** + * Unmarshalls a string into a ChainToken object. Follows the format of "chainId:token". + * + * @param chainTokenStr - The string to unmarshall. + * @returns The unmarshalled ChainToken object. + * @throws Will throw an error if the string is not in the correct format. + */ +export const unmarshallChainToken = (chainTokenStr: string): ChainToken => { + const items = chainTokenStr.split(':') + if (items.length !== 2) { + throw new Error(`Can not unmarshall "${chainTokenStr}": invalid format`) + } + // Check if the chain ID is a number + const chainId = Number(items[0]) + if (isNaN(chainId)) { + throw new Error( + `Can not unmarshall "${chainTokenStr}": ${items[0]} is not a chain ID` + ) + } + const token = getAddress(items[1]) + return { + chainId, + token, + } +} + +/** + * Marshalls a Ticker object into a string. Follows the format of "originChainId:originToken-destChainId:destToken". + * + * @param ticker - The Ticker object to marshall. + * @returns The marshalled string. + */ +export const marshallTicker = (ticker: Ticker): string => { + return `${marshallChainToken(ticker.originToken)}-${marshallChainToken( + ticker.destToken + )}` +} + +/** + * Unmarshalls a string into a Ticker object. Follows the format of "originChainId:originToken-destChainId:destToken". + * + * @param tickerStr - The string to unmarshall. + * @returns The unmarshalled Ticker object. + * @throws Will throw an error if the string is not in the correct format. + */ +export const unmarshallTicker = (tickerStr: string): Ticker => { + const items = tickerStr.split('-') + if (items.length !== 2) { + throw new Error(`Can not unmarshall "${tickerStr}": invalid format`) + } + const originToken = unmarshallChainToken(items[0]) + const destToken = unmarshallChainToken(items[1]) + return { + originToken, + destToken, + } +} diff --git a/packages/sdk-router/src/sdk.test.ts b/packages/sdk-router/src/sdk.test.ts index c22c929d44..b0fac6db7f 100644 --- a/packages/sdk-router/src/sdk.test.ts +++ b/packages/sdk-router/src/sdk.test.ts @@ -34,6 +34,13 @@ import { import { BridgeQuote, FeeConfig, RouterQuery, SwapQuote } from './module' import * as operations from './operations' +// Override fetch to exclude RFQ from tests +global.fetch = jest.fn(() => + Promise.resolve({ + json: () => Promise.resolve({}), + }) +) as any + const expectCorrectFeeConfig = (feeConfig: FeeConfig) => { expect(feeConfig).toBeDefined() expect(feeConfig.bridgeFee).toBeGreaterThan(0) @@ -135,6 +142,10 @@ describe('SynapseSDK', () => { getTestProviderUrl(SupportedChainId.ARBITRUM) ) + const opProvider: Provider = new providers.JsonRpcProvider( + getTestProviderUrl(SupportedChainId.OPTIMISM) + ) + const avaxProvider: Provider = new providers.JsonRpcProvider( getTestProviderUrl(SupportedChainId.AVALANCHE) ) @@ -808,8 +819,12 @@ describe('SynapseSDK', () => { describe('Bridge Tx Status', () => { const synapse = new SynapseSDK( - [SupportedChainId.ARBITRUM, SupportedChainId.ETH], - [arbProvider, ethProvider] + [ + SupportedChainId.ARBITRUM, + SupportedChainId.ETH, + SupportedChainId.OPTIMISM, + ], + [arbProvider, ethProvider, opProvider] ) // https://etherscan.io/tx/0xe3f0f0c1d139c48730492c900f9978449d70c0939c654d5abbfd6b191f9c7b3d @@ -848,6 +863,24 @@ describe('SynapseSDK', () => { '0xed98b02f712c940d3b37a1aa9005a5986ecefa5cdbb4505118a22ae65d4903af', } + // https://optimistic.etherscan.io/tx/0x1fa4c4b7a10d55e9ba833a15a1e6e57cb35cc0067190576193f7a77d4e71fbee + // https://arbiscan.io/tx/0x53a8e543bc0e3f0c1cae509e50d9435c3b62073eecf1aee7ece63c3be285db30 + const rfqOpToArbTx = { + txHash: + '0x1fa4c4b7a10d55e9ba833a15a1e6e57cb35cc0067190576193f7a77d4e71fbee', + synapseTxId: + '0xd0740c9ce06c2044fab4fd8519f7f20c9768431059daad3f965f6fec8d54a6c3', + } + + // https://arbiscan.io/tx/0xf0ebc85b3d83123c0d70aec1f1b5b525fa54140f5d2277154364ddede56bd69f + // https://optimistic.etherscan.io/tx/0xb96f8f3dbb886bc9eb7e3e43ba4aef863814299b71e133f8f447dba5d020a8b6 + const rfqArbToOpTx = { + txHash: + '0xf0ebc85b3d83123c0d70aec1f1b5b525fa54140f5d2277154364ddede56bd69f', + synapseTxId: + '0xf6524399bb7332a55377ffcf816f396759d5c753b2fa9137d5eca2db59741b2f', + } + describe('getSynapseTxId', () => { describe('SynapseBridge', () => { const ethSynBridge = '0x2796317b0fF8538F253012862c06787Adfb8cEb6' @@ -972,6 +1005,69 @@ describe('SynapseSDK', () => { }) }) + describe('SynapseRFQ', () => { + const arbSynRFQ = '0xA9EBFCb6DCD416FE975D5aB862717B329407f4F7' + const events = 'BridgeRequested' + + it('OP -> ARB', async () => { + const synapseTxId = await synapse.getSynapseTxId( + SupportedChainId.OPTIMISM, + 'SynapseRFQ', + rfqOpToArbTx.txHash + ) + expect(synapseTxId).toEqual(rfqOpToArbTx.synapseTxId) + }) + + it('ARB -> OP', async () => { + const synapseTxId = await synapse.getSynapseTxId( + SupportedChainId.ARBITRUM, + 'SynapseRFQ', + rfqArbToOpTx.txHash + ) + expect(synapseTxId).toEqual(rfqArbToOpTx.synapseTxId) + }) + + it('Throws when given a txHash that does not exist', async () => { + // Use txHash for another chain + await expect( + synapse.getSynapseTxId( + SupportedChainId.OPTIMISM, + 'SynapseRFQ', + rfqArbToOpTx.txHash + ) + ).rejects.toThrow('Failed to get transaction receipt') + }) + + it('Throws when origin tx does not refer to SynapseRFQ', async () => { + const errorMsg = + `Contract ${arbSynRFQ} in transaction ${bridgeArbToEthTx.txHash}` + + ` did not emit any of the expected events: ${events}` + await expect( + synapse.getSynapseTxId( + SupportedChainId.ARBITRUM, + 'SynapseRFQ', + bridgeArbToEthTx.txHash + ) + ).rejects.toThrow(errorMsg) + }) + + it('Throws when given a destination tx', async () => { + // Destination tx hash for OP -> ARB + const txHash = + '0x53a8e543bc0e3f0c1cae509e50d9435c3b62073eecf1aee7ece63c3be285db30' + const errorMsg = + `Contract ${arbSynRFQ} in transaction ${txHash}` + + ` did not emit any of the expected events: ${events}` + await expect( + synapse.getSynapseTxId( + SupportedChainId.ARBITRUM, + 'SynapseRFQ', + txHash + ) + ).rejects.toThrow(errorMsg) + }) + }) + it('Throws when bridge module name is invalid', async () => { await expect( synapse.getSynapseTxId( @@ -1064,6 +1160,46 @@ describe('SynapseSDK', () => { }) }) + describe('SynapseRFQ', () => { + it('OP -> ARB', async () => { + const txStatus = await synapse.getBridgeTxStatus( + SupportedChainId.ARBITRUM, + 'SynapseRFQ', + rfqOpToArbTx.synapseTxId + ) + expect(txStatus).toBe(true) + }) + + it('ARB -> OP', async () => { + const txStatus = await synapse.getBridgeTxStatus( + SupportedChainId.OPTIMISM, + 'SynapseRFQ', + rfqArbToOpTx.synapseTxId + ) + expect(txStatus).toBe(true) + }) + + it('Returns false when unknown synapseTxId', async () => { + // Using txHash instead of synapseTxId + const txStatus = await synapse.getBridgeTxStatus( + SupportedChainId.OPTIMISM, + 'SynapseRFQ', + rfqArbToOpTx.txHash + ) + expect(txStatus).toBe(false) + }) + + it('Returns false when origin chain is used instead of destination', async () => { + // First argument should be destination chainId + const txStatus = await synapse.getBridgeTxStatus( + SupportedChainId.OPTIMISM, + 'SynapseRFQ', + rfqOpToArbTx.synapseTxId + ) + expect(txStatus).toBe(false) + }) + }) + it('Throws when bridge module name is invalid', async () => { await expect( synapse.getBridgeTxStatus( @@ -1422,6 +1558,11 @@ describe('SynapseSDK', () => { expect(routerSet).toEqual(synapse.synapseCCTPRouterSet) }) + it('Returns correct set for SynapseRFQ', () => { + const routerSet = operations.getModuleSet.call(synapse, 'SynapseRFQ') + expect(routerSet).toEqual(synapse.fastBridgeSet) + }) + it('Throws when bridge module name is invalid', () => { expect(() => operations.getModuleSet.call(synapse, 'SynapseSynapse') diff --git a/packages/sdk-router/src/sdk.ts b/packages/sdk-router/src/sdk.ts index 59ad531b02..f9dedc0858 100644 --- a/packages/sdk-router/src/sdk.ts +++ b/packages/sdk-router/src/sdk.ts @@ -1,6 +1,7 @@ import { Provider } from '@ethersproject/abstract-provider' import invariant from 'tiny-invariant' +import { FastBridgeSet } from './rfq' import { SynapseRouterSet, SynapseCCTPRouterSet, @@ -21,6 +22,7 @@ class SynapseSDK { public allModuleSets: SynapseModuleSet[] public synapseRouterSet: SynapseRouterSet public synapseCCTPRouterSet: SynapseCCTPRouterSet + public fastBridgeSet: FastBridgeSet public providers: { [chainId: number]: Provider } /** @@ -45,10 +47,15 @@ class SynapseSDK { chainProviders.forEach((chainProvider) => { this.providers[chainProvider.chainId] = chainProvider.provider }) - // Initialize SynapseRouterSet and SynapseCCTPRouterSet + // Initialize the Module Sets this.synapseRouterSet = new SynapseRouterSet(chainProviders) this.synapseCCTPRouterSet = new SynapseCCTPRouterSet(chainProviders) - this.allModuleSets = [this.synapseRouterSet, this.synapseCCTPRouterSet] + this.fastBridgeSet = new FastBridgeSet(chainProviders) + this.allModuleSets = [ + this.synapseRouterSet, + this.synapseCCTPRouterSet, + this.fastBridgeSet, + ] } // Define Bridge operations diff --git a/packages/sdk-router/src/typechain/FastBridge.ts b/packages/sdk-router/src/typechain/FastBridge.ts index 67ec5f621e..ff6dbac88c 100644 --- a/packages/sdk-router/src/typechain/FastBridge.ts +++ b/packages/sdk-router/src/typechain/FastBridge.ts @@ -30,6 +30,7 @@ import type { export declare namespace IFastBridge { export type BridgeParamsStruct = { dstChainId: BigNumberish + sender: string to: string originToken: string destToken: string @@ -44,12 +45,14 @@ export declare namespace IFastBridge { string, string, string, + string, BigNumber, BigNumber, boolean, BigNumber ] & { dstChainId: number + sender: string to: string originToken: string destToken: string @@ -116,7 +119,7 @@ export interface FastBridgeInterface extends utils.Interface { 'addGovernor(address)': FunctionFragment 'addGuard(address)': FunctionFragment 'addRelayer(address)': FunctionFragment - 'bridge((uint32,address,address,address,uint256,uint256,bool,uint256))': FunctionFragment + 'bridge((uint32,address,address,address,address,uint256,uint256,bool,uint256))': FunctionFragment 'bridgeProofs(bytes32)': FunctionFragment 'bridgeRelays(bytes32)': FunctionFragment 'bridgeStatuses(bytes32)': FunctionFragment diff --git a/packages/sdk-router/src/utils/deadlines.ts b/packages/sdk-router/src/utils/deadlines.ts index 2723b080a0..6164fd5eba 100644 --- a/packages/sdk-router/src/utils/deadlines.ts +++ b/packages/sdk-router/src/utils/deadlines.ts @@ -2,6 +2,7 @@ import { BigNumber } from '@ethersproject/bignumber' // Default periods for deadlines on origin and destination chains respectively, in seconds export const TEN_MINUTES = 10 * 60 +export const ONE_HOUR = 60 * 60 export const ONE_WEEK = 7 * 24 * 60 * 60 export const calculateDeadline = (seconds: number) => { diff --git a/packages/synapse-interface/components/Portfolio/Activity.tsx b/packages/synapse-interface/components/Portfolio/Activity.tsx index cb13bec05e..aec3ecfac7 100644 --- a/packages/synapse-interface/components/Portfolio/Activity.tsx +++ b/packages/synapse-interface/components/Portfolio/Activity.tsx @@ -232,7 +232,7 @@ export const Activity = ({ visibility }: { visibility: boolean }) => { )} - {viewingAddress && !isLoading && hasPendingTransactions && ( + {/* {viewingAddress && !isLoading && hasPendingTransactions && ( {pendingAwaitingCompletionTransactionsWithFallback && pendingAwaitingCompletionTransactionsWithFallback.map( @@ -276,7 +276,7 @@ export const Activity = ({ visibility }: { visibility: boolean }) => { )} - )} + )} */} {viewingAddress && !isLoading && hasHistoricalTransactions && ( diff --git a/packages/synapse-interface/components/Portfolio/PortfolioTabManager.tsx b/packages/synapse-interface/components/Portfolio/PortfolioTabManager.tsx index 473844e143..284c5ed281 100644 --- a/packages/synapse-interface/components/Portfolio/PortfolioTabManager.tsx +++ b/packages/synapse-interface/components/Portfolio/PortfolioTabManager.tsx @@ -1,12 +1,15 @@ import { useAppDispatch } from '@/store/hooks' +import { useAccount } from 'wagmi' import { usePortfolioState } from '@/slices/portfolio/hooks' import { PortfolioTabs, setActiveTab } from '@/slices/portfolio/actions' import { MostRecentTransaction } from './Transaction/MostRecentTransaction' import { SearchBar } from './SearchBar' +import { _Transactions } from '../_Transaction/_Transactions' export const PortfolioTabManager = () => { const dispatch = useAppDispatch() const { activeTab } = usePortfolioState() + const { address } = useAccount() const handleTabChange = (newTab: PortfolioTabs) => { dispatch(setActiveTab(newTab)) @@ -29,11 +32,12 @@ export const PortfolioTabManager = () => { /> -
-
+ */} + <_Transactions connectedAddress={address} /> ) } diff --git a/packages/synapse-interface/components/Portfolio/Transaction/PendingTransaction.tsx b/packages/synapse-interface/components/Portfolio/Transaction/PendingTransaction.tsx index 66c165627c..5d512232e2 100644 --- a/packages/synapse-interface/components/Portfolio/Transaction/PendingTransaction.tsx +++ b/packages/synapse-interface/components/Portfolio/Transaction/PendingTransaction.tsx @@ -151,10 +151,12 @@ export const PendingTransaction = ({ if (moduleName === 'SynapseBridge') return BridgeType.Bridge if (moduleName === 'SynapseCCTP') return BridgeType.Cctp + if (moduleName === 'SynapseRFQ') return BridgeType.Rfq } if (synapseSDK && bridgeModuleName) { if (bridgeModuleName === 'SynapseBridge') return BridgeType.Bridge if (bridgeModuleName === 'SynapseCCTP') return BridgeType.Cctp + if (bridgeModuleName === 'SynapseRFQ') return BridgeType.Rfq } return BridgeType.Bridge }, [synapseSDK, bridgeModuleName, formattedEventType]) @@ -181,7 +183,7 @@ export const PendingTransaction = ({ transactionHash, bridgeModuleName, kappa, - checkStatus: isDelayed, + checkStatus: useFallback || (isDelayed && isReconnectedAndRetryFallback), elapsedTime: updatedElapsedTime, }) diff --git a/packages/synapse-interface/components/StateManagedBridge/BridgeExchangeRateInfo.tsx b/packages/synapse-interface/components/StateManagedBridge/BridgeExchangeRateInfo.tsx index 56810c3c44..0ec3559f1f 100644 --- a/packages/synapse-interface/components/StateManagedBridge/BridgeExchangeRateInfo.tsx +++ b/packages/synapse-interface/components/StateManagedBridge/BridgeExchangeRateInfo.tsx @@ -19,7 +19,15 @@ const BridgeExchangeRateInfo = ({ showGasDrop }: { showGasDrop: boolean }) => { (state: RootState) => state.bridge.bridgeQuote.exchangeRate ) const toChainId = useSelector((state: RootState) => state.bridge.toChainId) - const { gasDrop: gasDropAmount, loading } = useGasDropAmount(toChainId) + // TODO: this is ugly, refactor + const bridgeModuleName = useSelector( + (state: RootState) => state.bridge.bridgeQuote.bridgeModuleName + ) + let { gasDrop: gasDropAmount, loading } = useGasDropAmount(toChainId) + if (bridgeModuleName === 'SynapseRFQ') { + gasDropAmount = 0n + loading = false + } const safeExchangeRate = typeof exchangeRate === 'bigint' ? exchangeRate : 0n const safeFromAmount = fromAmount ?? '0' diff --git a/packages/synapse-interface/components/_Transaction/_Transaction.tsx b/packages/synapse-interface/components/_Transaction/_Transaction.tsx new file mode 100644 index 0000000000..1c0369c833 --- /dev/null +++ b/packages/synapse-interface/components/_Transaction/_Transaction.tsx @@ -0,0 +1,303 @@ +import { useMemo, useEffect, useState, useCallback } from 'react' +import { useAppDispatch } from '@/store/hooks' +import { use_TransactionsState } from '@/slices/_transactions/hooks' +import { getTxBlockExplorerLink } from './helpers/getTxBlockExplorerLink' +import { getExplorerAddressLink } from './helpers/getExplorerAddressLink' +import { useBridgeTxStatus } from './helpers/useBridgeTxStatus' +import { isNull } from 'lodash' +import { DownArrow } from '../icons/DownArrow' +import { + updateTransactionKappa, + completeTransaction, + removeTransaction, +} from '@/slices/_transactions/reducer' +import { TransactionPayloadDetail } from '../Portfolio/Transaction/components/TransactionPayloadDetail' +import { Chain, Token } from '@/utils/types' +import TransactionArrow from '../icons/TransactionArrow' + +const TransactionStatus = ({ string }) => { + return <>{string} +} + +const TimeRemaining = ({ + isComplete, + remainingTime, + isDelayed, +}: { + isComplete: boolean + remainingTime: number + isDelayed: boolean +}) => { + if (isComplete) return + + if (isDelayed) { + return
Waiting...
+ } + + return
{remainingTime} min
+} + +interface _TransactionProps { + connectedAddress: string + originValue: number + originChain: Chain + originToken: Token + destinationChain: Chain + destinationToken: Token + originTxHash: string + bridgeModuleName: string + estimatedTime: number // in seconds + timestamp: number + currentTime: number + kappa?: string + isStoredComplete: boolean +} + +/** TODO: Update naming after refactoring existing Activity / Transaction flow */ +export const _Transaction = ({ + connectedAddress, + originValue, + originChain, + originToken, + destinationChain, + destinationToken, + originTxHash, + bridgeModuleName, + estimatedTime, + timestamp, + currentTime, + kappa, + isStoredComplete, +}: _TransactionProps) => { + const dispatch = useAppDispatch() + const { transactions } = use_TransactionsState() + + const [originTxExplorerLink, originExplorerName] = getTxBlockExplorerLink( + originChain.id, + originTxHash + ) + const [destExplorerAddressLink, destExplorerName] = getExplorerAddressLink( + destinationChain.id, + connectedAddress + ) + + const elapsedTime: number = currentTime - timestamp // in seconds + const remainingTime: number = estimatedTime - elapsedTime + const remainingTimeInMinutes: number = Math.ceil(remainingTime / 60) // add additional min for buffer + + const isEstimatedTimeReached: boolean = useMemo(() => { + // Define the interval in minutes before the estimated completion when we should start checking + const intervalBeforeCompletion = 1 // X minutes before completion + // Calculate the time in seconds when we should start checking + const startCheckingTime = + currentTime + estimatedTime - intervalBeforeCompletion * 60 + + // if current time is above startCheckingTime, return true to begin calling the SDK + return currentTime >= startCheckingTime + + // TODO: OLD CODE BELOW: + // if (!currentTime || !estimatedTime || !timestamp) return false + // return currentTime - timestamp > estimatedTime + }, [estimatedTime, currentTime, timestamp]) + + const [isTxComplete, _kappa] = useBridgeTxStatus({ + originChainId: originChain.id, + destinationChainId: destinationChain.id, + originTxHash, + bridgeModuleName, + kappa: kappa, + checkStatus: !isStoredComplete || isEstimatedTimeReached, + currentTime: currentTime, + }) + + /** Check if store already marked tx as complete, otherwise check hook status */ + const isTxCompleted = isStoredComplete ?? isTxComplete + + /** Update tx kappa when available */ + useEffect(() => { + if (_kappa && originTxHash) { + dispatch( + updateTransactionKappa({ originTxHash, kappa: _kappa as string }) + ) + } + }, [_kappa, dispatch]) + + /** Update tx for completion */ + /** Check that we have not already marked tx as complete */ + useEffect(() => { + const txKappa = _kappa + + if (isTxComplete && originTxHash && txKappa) { + const txn = transactions.find((tx) => tx.originTxHash === originTxHash) + if (!txn.isComplete) { + dispatch( + completeTransaction({ originTxHash, kappa: txKappa as string }) + ) + } + } + }, [isTxComplete, dispatch, transactions, _kappa]) + + const handleClearTransaction = useCallback(() => { + dispatch(removeTransaction({ originTxHash })) + }, [dispatch]) + + return ( +
+
+
+ + +
+
+ +
+ {new Date(timestamp * 1000).toLocaleString('en-US', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: true, + })} + {/*
{typeof _kappa === 'string' && _kappa?.substring(0, 15)}
*/} +
+
+ {/* TODO: Update visual format */} +
+ {isTxCompleted ? ( + + ) : ( + + )} +
+ + + + {!isNull(originTxExplorerLink) && ( + + )} + {!isNull(destExplorerAddressLink) && ( + + )} + + {isTxComplete && ( + + )} + +
+
+
+
+ ) +} + +export const DropdownMenu = ({ children }) => { + const [open, setOpen] = useState(false) + + const handleClick = () => { + setOpen(!open) + } + + return ( +
+
+ +
+ + {open && ( +
    + {children} +
+ )} +
+ ) +} + +export const MenuItem = ({ + text, + link, + onClick, +}: { + text: string + link: string + onClick?: () => any +}) => { + return ( +
  • + {onClick ? ( +
    + {text} +
    + ) : ( + + {text} + + )} +
  • + ) +} diff --git a/packages/synapse-interface/components/_Transaction/_Transactions.tsx b/packages/synapse-interface/components/_Transaction/_Transactions.tsx new file mode 100644 index 0000000000..8cb02c4fd5 --- /dev/null +++ b/packages/synapse-interface/components/_Transaction/_Transactions.tsx @@ -0,0 +1,60 @@ +import _ from 'lodash' +import { useState, useEffect, useMemo } from 'react' +import { use_TransactionsState } from '@/slices/_transactions/hooks' +import { _TransactionDetails } from '@/slices/_transactions/reducer' +import { _Transaction } from './_Transaction' +import { getTimeMinutesFromNow } from '@/utils/time' +import { checkTransactionsExist } from '@/utils/checkTransactionsExist' + +/** TODO: Update naming once refactoring of previous Activity/Tx flow is done */ +export const _Transactions = ({ + connectedAddress, +}: { + connectedAddress: string +}) => { + const { transactions } = use_TransactionsState() + + const hasTransactions: boolean = checkTransactionsExist(transactions) + + const [currentTime, setCurrentTime] = useState( + getTimeMinutesFromNow(0) + ) + + /** Update time to trigger transactions to recheck tx status */ + useEffect(() => { + const interval = setInterval(() => { + let newCurrentTime = getTimeMinutesFromNow(0) + setCurrentTime(newCurrentTime) + }, 5000) // 5000 milliseconds = 5 seconds + + return () => { + clearInterval(interval) // Clear the interval when the component unmounts + } + }, []) + + if (hasTransactions) { + const sortedTransactions = _.orderBy(transactions, ['timestamp'], ['desc']) + return ( +
    + {sortedTransactions.slice(0, 5).map((tx: _TransactionDetails) => ( + <_Transaction + key={tx.timestamp} + connectedAddress={connectedAddress} + originValue={Number(tx.originValue)} + originChain={tx.originChain} + originToken={tx.originToken} + destinationChain={tx.destinationChain} + destinationToken={tx.destinationToken} + originTxHash={tx.originTxHash} + bridgeModuleName={tx.bridgeModuleName} + estimatedTime={tx.estimatedTime} + kappa={tx?.kappa} + timestamp={tx.timestamp} + currentTime={currentTime} + isStoredComplete={tx.isComplete} + /> + ))} +
    + ) + } +} diff --git a/packages/synapse-interface/components/_Transaction/helpers/constants.ts b/packages/synapse-interface/components/_Transaction/helpers/constants.ts new file mode 100644 index 0000000000..402a366ce9 --- /dev/null +++ b/packages/synapse-interface/components/_Transaction/helpers/constants.ts @@ -0,0 +1,43 @@ +export const ExplorerLinks = { + 1: 'https://etherscan.com', + 42161: 'https://arbiscan.io', + 56: 'https://bscscan.com', + 43114: 'https://snowtrace.io/', + 7700: 'https://tuber.build/', + 10: 'https://optimistic.etherscan.io', + 137: 'https://polygonscan.com', + 53935: 'https://subnets.avax.network/defi-kingdoms', + 8217: 'https://scope.klaytn.com', + 250: 'https://ftmscan.com', + 25: 'https://cronoscan.com', + 288: 'https://bobascan.com', + 1088: 'https://andromeda-explorer.metis.io', + 1313161554: 'https://explorer.mainnet.aurora.dev', + 1666600000: 'https://explorer.harmony.one', + 1284: 'https://moonbeam.moonscan.io', + 1285: 'https://moonriver.moonscan.io', + 2000: 'https://explorer.dogechain.dog', + 8453: 'https://basescan.org', +} + +export const ExplorerNames = { + 1: 'Etherscan', + 42161: 'Arbiscan', + 56: 'BscScan', + 43114: 'Snowtrace', + 7700: 'Canto Explorer', + 10: 'Optimism Explorer', + 137: 'PolygonScan', + 53935: 'DFK Subnet Explorer', + 8217: 'Klaytn Explorer', + 250: 'FTMScan', + 25: 'CronoScan', + 288: 'Boba Explorer', + 1088: 'Metis Explorer', + 1313161554: 'Aurora Explorer', + 1666600000: 'Harmony Explorer', + 1284: 'Moonbeam Explorer', + 1285: 'Moonriver Explorer', + 2000: 'Dogechain Explorer', + 8453: 'BaseScan', +} diff --git a/packages/synapse-interface/components/_Transaction/helpers/getExplorerAddressLink.ts b/packages/synapse-interface/components/_Transaction/helpers/getExplorerAddressLink.ts new file mode 100644 index 0000000000..974f490aaa --- /dev/null +++ b/packages/synapse-interface/components/_Transaction/helpers/getExplorerAddressLink.ts @@ -0,0 +1,15 @@ +import { ExplorerLinks, ExplorerNames } from './constants' + +export const getExplorerAddressLink = (chainId: number, address: string) => { + const blockExplorer = ExplorerLinks[chainId] + + if (blockExplorer && address) { + const explorerUrl = `${blockExplorer}/address/${address}` + const explorerName = ExplorerNames[chainId] + + return [explorerUrl, explorerName] + } + + console.error('getExplorerAddressLink: ChainId or Address missing') + return [null, null] +} diff --git a/packages/synapse-interface/components/_Transaction/helpers/getTxBlockExplorerLink.ts b/packages/synapse-interface/components/_Transaction/helpers/getTxBlockExplorerLink.ts new file mode 100644 index 0000000000..2b1d8b5fe3 --- /dev/null +++ b/packages/synapse-interface/components/_Transaction/helpers/getTxBlockExplorerLink.ts @@ -0,0 +1,15 @@ +import { ExplorerLinks, ExplorerNames } from './constants' + +export const getTxBlockExplorerLink = (chainId: number, txHash: string) => { + const blockExplorer = ExplorerLinks[chainId] + + if (blockExplorer && txHash) { + const explorerUrl = `${blockExplorer}/tx/${txHash}` + const explorerName = ExplorerNames[chainId] + + return [explorerUrl, explorerName] + } + + console.error('getTxBlockExplorerLink: ChainID or Transaction Hash missing') + return [null, null] +} diff --git a/packages/synapse-interface/components/_Transaction/helpers/useBridgeTxStatus.tsx b/packages/synapse-interface/components/_Transaction/helpers/useBridgeTxStatus.tsx new file mode 100644 index 0000000000..423e6db51b --- /dev/null +++ b/packages/synapse-interface/components/_Transaction/helpers/useBridgeTxStatus.tsx @@ -0,0 +1,90 @@ +import { useState, useEffect } from 'react' +import { useSynapseContext } from '@/utils/providers/SynapseProvider' + +interface UseBridgeTxStatusProps { + originChainId: number + destinationChainId: number + originTxHash: string + bridgeModuleName?: string + kappa?: string + checkStatus: boolean + currentTime: number // used as trigger to refetch status +} + +export const useBridgeTxStatus = ({ + originChainId, + destinationChainId, + originTxHash, + bridgeModuleName, + kappa, + checkStatus = false, + currentTime, +}: UseBridgeTxStatusProps): [boolean, string] => { + const { synapseSDK } = useSynapseContext() + const [isComplete, setIsComplete] = useState(false) + const [fetchedKappa, setFetchedKappa] = useState(kappa ?? null) + + const getKappa = async (): Promise => { + if (!synapseSDK) return null + if (!bridgeModuleName || !originChainId || !originTxHash) return null + try { + const kappa = await synapseSDK.getSynapseTxId( + originChainId, + bridgeModuleName, + originTxHash + ) + return kappa + } catch (error) { + console.error('Error in getKappa:', error) + return null + } + } + + const getBridgeTxStatus = async ( + destinationChainId: number, + bridgeModuleName: string, + kappa: string + ) => { + if (!synapseSDK) return null + if (!destinationChainId || !bridgeModuleName || !kappa) return null + try { + const status = await synapseSDK.getBridgeTxStatus( + destinationChainId, + bridgeModuleName, + kappa + ) + + return status + } catch (error) { + console.error('Error in getBridgeTxStatus:', error) + return null + } + } + + useEffect(() => { + if (!checkStatus) return + if (isComplete) return + ;(async () => { + if (fetchedKappa === null) { + let _kappa = await getKappa() + setFetchedKappa(_kappa) + } + + if (fetchedKappa) { + const txStatus = await getBridgeTxStatus( + destinationChainId, + bridgeModuleName, + fetchedKappa + ) + + if (txStatus !== null && txStatus === true && fetchedKappa !== null) { + setIsComplete(true) + } else { + setIsComplete(false) + } + } + })() + }, [currentTime, checkStatus, fetchedKappa]) + + return [isComplete, fetchedKappa] +} diff --git a/packages/synapse-interface/pages/_app.tsx b/packages/synapse-interface/pages/_app.tsx index 9b1d02bfe9..ec17242e96 100644 --- a/packages/synapse-interface/pages/_app.tsx +++ b/packages/synapse-interface/pages/_app.tsx @@ -53,6 +53,7 @@ import ApplicationUpdater from '@/slices/application/updater' import BridgeUpdater from '@/slices/bridge/updater' import PortfolioUpdater from '@/slices/portfolio/updater' import TransactionsUpdater from '@/slices/transactions/updater' +import _TransactionsUpdater from '@/slices/_transactions/updater' const rawChains = [ mainnet, @@ -137,6 +138,7 @@ function Updaters() { + <_TransactionsUpdater /> ) diff --git a/packages/synapse-interface/pages/state-managed-bridge/index.tsx b/packages/synapse-interface/pages/state-managed-bridge/index.tsx index 00459f6c9d..f9163dc50d 100644 --- a/packages/synapse-interface/pages/state-managed-bridge/index.tsx +++ b/packages/synapse-interface/pages/state-managed-bridge/index.tsx @@ -239,12 +239,13 @@ const StateManagedBridge = () => { ) } - const originMinWithSlippage = subtractSlippage( + // TODO: do this properly (RFQ needs no slippage, others do) + const originMinWithSlippage = bridgeModuleName === "SynapseRFQ" ? (originQuery?.minAmountOut ?? 0n) : subtractSlippage( originQuery?.minAmountOut ?? 0n, 'ONE_TENTH', null ) - const destMinWithSlippage = subtractSlippage( + const destMinWithSlippage = bridgeModuleName === "SynapseRFQ" ? (destQuery?.minAmountOut ?? 0n) : subtractSlippage( destQuery?.minAmountOut ?? 0n, 'ONE_TENTH', null diff --git a/packages/synapse-interface/slices/_transactions/hooks.ts b/packages/synapse-interface/slices/_transactions/hooks.ts new file mode 100644 index 0000000000..4ba609ed5a --- /dev/null +++ b/packages/synapse-interface/slices/_transactions/hooks.ts @@ -0,0 +1,6 @@ +import { useAppSelector } from '@/store/hooks' +import { RootState } from '@/store/store' + +export const use_TransactionsState = (): RootState['_transactions'] => { + return useAppSelector((state) => state._transactions) +} diff --git a/packages/synapse-interface/slices/_transactions/reducer.ts b/packages/synapse-interface/slices/_transactions/reducer.ts new file mode 100644 index 0000000000..7a25369be6 --- /dev/null +++ b/packages/synapse-interface/slices/_transactions/reducer.ts @@ -0,0 +1,85 @@ +import { PayloadAction, createSlice } from '@reduxjs/toolkit' + +import { Chain, Token } from '@/utils/types' + +/** TODO: Rename entire slice once done refactoring prior Activity flow */ +export interface _TransactionDetails { + originChain: Chain + originToken: Token + destinationChain: Chain + destinationToken: Token + originValue: string + originTxHash: string + bridgeModuleName: string + estimatedTime: number + timestamp: number + kappa: string + isComplete: boolean +} + +export interface _TransactionsState { + transactions: any[] +} + +export const initialState: _TransactionsState = { + transactions: [], +} + +export const transactionsSlice = createSlice({ + name: '_transactions', + initialState, + reducers: { + addTransaction: (state, action: PayloadAction) => { + state.transactions.push(action.payload) + }, + removeTransaction: ( + state, + action: PayloadAction<{ originTxHash: string }> + ) => { + const { originTxHash } = action.payload + state.transactions = state.transactions.filter( + (tx) => tx.originTxHash !== originTxHash + ) + }, + updateTransactionKappa: ( + state, + action: PayloadAction<{ originTxHash: string; kappa: string }> + ) => { + const { originTxHash, kappa } = action.payload + + const txIndex = state.transactions.findIndex( + (tx) => tx.originTxHash === originTxHash + ) + + if (txIndex !== -1) { + state.transactions[txIndex].kappa = kappa + } + }, + completeTransaction: ( + state, + action: PayloadAction<{ originTxHash: string; kappa: string }> + ) => { + const { originTxHash } = action.payload + + const txIndex = state.transactions.findIndex( + (tx) => tx.originTxHash === originTxHash + ) + if (txIndex !== -1) { + state.transactions[txIndex].isComplete = true + } + }, + clearTransactions: (state) => { + state.transactions = [] + }, + }, +}) + +export const { + addTransaction, + removeTransaction, + updateTransactionKappa, + completeTransaction, + clearTransactions, +} = transactionsSlice.actions + +export default transactionsSlice.reducer diff --git a/packages/synapse-interface/slices/_transactions/updater.tsx b/packages/synapse-interface/slices/_transactions/updater.tsx new file mode 100644 index 0000000000..cb9d828884 --- /dev/null +++ b/packages/synapse-interface/slices/_transactions/updater.tsx @@ -0,0 +1,58 @@ +import { useEffect } from 'react' +import { useAppDispatch } from '@/store/hooks' +import { use_TransactionsState } from './hooks' +import { addTransaction } from './reducer' +import { useTransactionsState } from '../transactions/hooks' +import { checkTransactionsExist } from '@/utils/checkTransactionsExist' +import { + PendingBridgeTransaction, + removePendingBridgeTransaction, +} from '../transactions/actions' +import _ from 'lodash' + +export default function Updater() { + const dispatch = useAppDispatch() + const { pendingBridgeTransactions } = useTransactionsState() + const { transactions } = use_TransactionsState() + + /** Add transaction if not in _transactions store */ + useEffect(() => { + if (checkTransactionsExist(pendingBridgeTransactions)) { + pendingBridgeTransactions.forEach((tx: PendingBridgeTransaction) => { + /** Check Transaction has been confirmed */ + const txnConfirmed = + !_.isNull(tx.transactionHash) && !_.isUndefined(tx.transactionHash) + + /** Check Transaction is already stored */ + const txnExists = + transactions && + transactions.some( + (storedTx) => tx.transactionHash == storedTx.originTxHash + ) + + /** Remove pendingBridgeTransaction once stored in transactions */ + if (txnExists) { + dispatch(removePendingBridgeTransaction(tx.id)) + } + + if (txnConfirmed && !txnExists) { + dispatch( + addTransaction({ + originTxHash: tx.transactionHash, + originValue: tx.originValue, + originChain: tx.originChain, + originToken: tx.originToken, + destinationChain: tx.destinationChain, + destinationToken: tx.destinationToken, + bridgeModuleName: tx.bridgeModuleName, + estimatedTime: tx.estimatedTime, + timestamp: tx.id, + }) + ) + } + }) + } + }, [pendingBridgeTransactions, transactions]) + + return null +} diff --git a/packages/synapse-interface/slices/api/generated.ts b/packages/synapse-interface/slices/api/generated.ts index 25aeb9ccce..568e828e8a 100644 --- a/packages/synapse-interface/slices/api/generated.ts +++ b/packages/synapse-interface/slices/api/generated.ts @@ -86,11 +86,13 @@ export type BridgeTransaction = { export enum BridgeTxType { Destination = 'DESTINATION', Origin = 'ORIGIN', + Rfq = 'RFQ', } export enum BridgeType { Bridge = 'BRIDGE', Cctp = 'CCTP', + Rfq = 'RFQ', } /** BridgeWatcherTx represents a single sided bridge transaction specifically for the bridge watcher. */ @@ -111,6 +113,7 @@ export type ContractQuery = { export enum ContractType { Bridge = 'BRIDGE', Cctp = 'CCTP', + Rfq = 'RFQ', } export enum DailyStatisticType { diff --git a/packages/synapse-interface/store/reducer.ts b/packages/synapse-interface/store/reducer.ts index 6828b18c6e..803d8541ef 100644 --- a/packages/synapse-interface/store/reducer.ts +++ b/packages/synapse-interface/store/reducer.ts @@ -3,6 +3,7 @@ import { PersistConfig, persistReducer } from 'redux-persist' import storage from 'redux-persist/lib/storage' import application from '@/slices/application/reducer' +import _transactions from '@/slices/_transactions/reducer' import bridge from '@/slices/bridge/reducer' import portfolio from '@/slices/portfolio/reducer' import swap from '@/slices/swap/reducer' @@ -20,6 +21,7 @@ import { RootActions } from '@/slices/application/actions' const persistedReducers = { application, transactions, + _transactions, } export const storageKey: string = 'synapse-interface' diff --git a/packages/synapse-interface/utils/actions/fetchBridgeQuotes.tsx b/packages/synapse-interface/utils/actions/fetchBridgeQuotes.tsx index c86a9d872d..ea18d402b7 100644 --- a/packages/synapse-interface/utils/actions/fetchBridgeQuotes.tsx +++ b/packages/synapse-interface/utils/actions/fetchBridgeQuotes.tsx @@ -60,12 +60,13 @@ export async function fetchBridgeQuote( ? BigInt(feeAmount) : BigInt(feeAmount) / powBigInt(10n, BigInt(18 - originTokenDecimals)) - const originMinWithSlippage = subtractSlippage( + // TODO: do this properly (RFQ needs no slippage, others do) + const originMinWithSlippage = bridgeModuleName === "SynapseRFQ" ? (originQuery?.minAmountOut ?? 0n) : subtractSlippage( originQuery?.minAmountOut ?? 0n, 'ONE_TENTH', null ) - const destMinWithSlippage = subtractSlippage( + const destMinWithSlippage = bridgeModuleName === "SynapseRFQ" ? (destQuery?.minAmountOut ?? 0n) : subtractSlippage( destQuery?.minAmountOut ?? 0n, 'ONE_TENTH', null diff --git a/yarn.lock b/yarn.lock index beebc8e732..b9b66fb051 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20418,6 +20418,13 @@ jest-message-util@^29.6.2: slash "^3.0.0" stack-utils "^2.0.3" +jest-mock-extended@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/jest-mock-extended/-/jest-mock-extended-3.0.5.tgz#ebf208e363f4f1db603b81fb005c4055b7c1c8b7" + integrity sha512-/eHdaNPUAXe7f65gHH5urc8SbRVWjYxBqmCgax2uqOBJy8UUcCBMN1upj1eZ8y/i+IqpyEm4Kq0VKss/GCCTdw== + dependencies: + ts-essentials "^7.0.3" + jest-mock@^25.5.0: version "25.5.0" resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-25.5.0.tgz#a91a54dabd14e37ecd61665d6b6e06360a55387a" @@ -23276,6 +23283,13 @@ node-fetch-native@^1.4.0, node-fetch-native@^1.4.1: resolved "https://registry.yarnpkg.com/node-fetch-native/-/node-fetch-native-1.4.1.tgz#5a336e55b4e1b1e72b9927da09fecd2b374c9be5" integrity sha512-NsXBU0UgBxo2rQLOeWNZqS3fvflWePMECr8CoSWoSTqCqGbVVsvl9vZu1HfQicYN0g5piV9Gh8RTEvo/uP752w== +node-fetch@^2.0.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + node-fetch@^2.6.1, node-fetch@^2.6.12, node-fetch@^2.6.7: version "2.6.12" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.12.tgz#02eb8e22074018e3d5a83016649d04df0e348fba" @@ -29548,7 +29562,7 @@ ts-dedent@^2.0.0, ts-dedent@^2.2.0: resolved "https://registry.yarnpkg.com/ts-dedent/-/ts-dedent-2.2.0.tgz#39e4bd297cd036292ae2394eb3412be63f563bb5" integrity sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ== -ts-essentials@^7.0.1: +ts-essentials@^7.0.1, ts-essentials@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-7.0.3.tgz#686fd155a02133eedcc5362dc8b5056cde3e5a38" integrity sha512-8+gr5+lqO3G84KdiTSMRLtuyJ+nTBVRKuCrK4lidMPdVeEp0uqC875uE5NMcaA7YYMN7XsNiFQuMvasF8HT/xQ==