diff --git a/packages/synapse-interface/__tests__/bridgeQuoteSingleFlight.test.ts b/packages/synapse-interface/__tests__/bridgeQuoteSingleFlight.test.ts new file mode 100644 index 0000000000..bcd5da6279 --- /dev/null +++ b/packages/synapse-interface/__tests__/bridgeQuoteSingleFlight.test.ts @@ -0,0 +1,84 @@ +/** @jest-environment node */ +import { bridgeQuoteSingleFlight } from '@/utils/bridgeQuoteSingleFlight' + +const params = { + fromChainId: 1, + toChainId: 1337, + fromToken: 'SYN', + toToken: 'SYN_CORE', + fromAmount: '1000000000000000000', + fromSender: 'alice', + toRecipient: 'bob', + slippagePercentage: 0.1, +} + +describe('bridge quote single flight', () => { + it('coalesces identical in-flight SDK work without caching resolved quotes', async () => { + let resolve: (value: any) => void + const sdk = { + bridgeV2: jest + .fn() + .mockImplementationOnce( + () => + new Promise((done) => { + resolve = done + }) + ) + .mockResolvedValue(['new quote']), + } + const first = bridgeQuoteSingleFlight(sdk, params) + const second = bridgeQuoteSingleFlight( + sdk, + Object.fromEntries(Object.entries(params).reverse()) + ) + expect(second).toBe(first) + await Promise.resolve() + expect(sdk.bridgeV2).toHaveBeenCalledTimes(1) + resolve(['first quote']) + expect(await first).toEqual(['first quote']) + expect(await bridgeQuoteSingleFlight(sdk, params)).toEqual(['new quote']) + expect(sdk.bridgeV2).toHaveBeenCalledTimes(2) + }) + + it('separates every SDK parameter and SDK instance', async () => { + const sdk = { bridgeV2: jest.fn().mockResolvedValue([]) } + const calls = [bridgeQuoteSingleFlight(sdk, params)] + for (const field of Object.keys(params)) { + calls.push( + bridgeQuoteSingleFlight(sdk, { + ...params, + [field]: String(params[field]) + 'changed', + }) + ) + } + const otherSDK = { bridgeV2: jest.fn().mockResolvedValue([]) } + calls.push(bridgeQuoteSingleFlight(otherSDK, params)) + await Promise.all(calls) + expect(sdk.bridgeV2).toHaveBeenCalledTimes(Object.keys(params).length + 1) + expect(otherSDK.bridgeV2).toHaveBeenCalledTimes(1) + }) + + it('releases failed requests so a later refresh can retry', async () => { + const sdk = { + bridgeV2: jest + .fn() + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValue([]), + } + const request = bridgeQuoteSingleFlight(sdk, params) + expect(bridgeQuoteSingleFlight(sdk, params)).toBe(request) + await expect(request).rejects.toThrow('offline') + await expect(bridgeQuoteSingleFlight(sdk, params)).resolves.toEqual([]) + expect(sdk.bridgeV2).toHaveBeenCalledTimes(2) + }) + + it('distinguishes undefined, null, and omitted parameters', async () => { + const sdk = { bridgeV2: jest.fn().mockResolvedValue([]) } + await Promise.all([ + bridgeQuoteSingleFlight(sdk, { ...params, deadline: undefined }), + bridgeQuoteSingleFlight(sdk, { ...params, deadline: null }), + bridgeQuoteSingleFlight(sdk, params), + ]) + expect(sdk.bridgeV2).toHaveBeenCalledTimes(3) + }) +}) diff --git a/packages/synapse-interface/__tests__/synBridgeQuote.test.ts b/packages/synapse-interface/__tests__/synBridgeQuote.test.ts index 3c4a50655a..cfd577aeec 100644 --- a/packages/synapse-interface/__tests__/synBridgeQuote.test.ts +++ b/packages/synapse-interface/__tests__/synBridgeQuote.test.ts @@ -35,12 +35,14 @@ const runQuote = ({ toChainId = 1337, address = sender, destinationAddress, + requestId = 1, }: { synapseSDK: any fromChainId?: number toChainId?: number address?: string | null destinationAddress?: string + requestId?: number }) => fetchBridgeQuote({ synapseSDK, @@ -49,7 +51,7 @@ const runQuote = ({ fromToken: SYN, toToken: SYN, debouncedFromValue: '1', - requestId: 1, + requestId, currentTimestamp: 123, address: address as any, destinationAddress: destinationAddress as any, @@ -59,6 +61,22 @@ const runQuote = ({ describe('SYN bridge quote state', () => { beforeEach(() => jest.clearAllMocks()) + it('shares simultaneous SDK quotes while preserving each caller request ID', async () => { + const synapseSDK = { + bridgeV2: jest.fn().mockResolvedValue([quote()]), + synModuleSet: { + isHyperCoreAccountActive: jest.fn().mockResolvedValue(true), + }, + } + const [first, second] = await Promise.all([ + runQuote({ synapseSDK, requestId: 1 }), + runQuote({ synapseSDK, requestId: 2 }), + ]) + expect(synapseSDK.bridgeV2).toHaveBeenCalledTimes(1) + expect(first.payload).toMatchObject({ requestId: 1 }) + expect(second.payload).toMatchObject({ requestId: 2 }) + }) + it.each([ ['inactive custom recipient', recipient, false], ['active connected recipient', undefined, true], diff --git a/packages/synapse-interface/__tests__/synapseProvider.test.tsx b/packages/synapse-interface/__tests__/synapseProvider.test.tsx new file mode 100644 index 0000000000..41c7a830b6 --- /dev/null +++ b/packages/synapse-interface/__tests__/synapseProvider.test.tsx @@ -0,0 +1,51 @@ +import { renderHook } from '@testing-library/react' +import { SynapseSDK } from '@synapsecns/sdk-router' +import { FallbackProvider } from '@ethersproject/providers' + +import { + SynapseProvider, + useSynapseContext, +} from '@/utils/providers/SynapseProvider' + +jest.mock('@synapsecns/sdk-router', () => ({ SynapseSDK: jest.fn() })) +jest.mock('../utils/providers/TransportAwareJsonRpcProvider', () => ({ + TransportAwareJsonRpcProvider: jest + .fn() + .mockImplementation((url, id) => ({ url, network: { chainId: id } })), +})) +jest.mock('@ethersproject/providers', () => ({ + FallbackProvider: jest.fn().mockImplementation((configs) => ({ + configs, + network: configs[0].provider.network, + })), +})) + +const initial = [ + { id: 1, configRpc: 'https://primary', fallbackRpc: 'https://fallback' }, +] + +describe('interface SDK provider lifetime', () => { + beforeEach(() => { + jest.clearAllMocks() + ;(SynapseSDK as jest.Mock).mockImplementation(() => ({})) + }) + + it('preserves SDK and providers across children and equivalent chain prop changes', () => { + let chains = initial + const wrapper = ({ children }) => ( + {children} + ) + const { result, rerender } = renderHook(useSynapseContext, { wrapper }) + const context = result.current + chains = initial.map((chain) => ({ ...chain })) + rerender() + expect(result.current).toBe(context) + expect(SynapseSDK).toHaveBeenCalledTimes(1) + expect(FallbackProvider).toHaveBeenCalledTimes(1) + chains = [{ ...initial[0], fallbackRpc: 'https://replacement' }] + rerender() + expect(result.current).not.toBe(context) + expect(SynapseSDK).toHaveBeenCalledTimes(2) + expect(FallbackProvider).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/synapse-interface/pages/state-managed-bridge/index.tsx b/packages/synapse-interface/pages/state-managed-bridge/index.tsx index 1176137d39..307735b480 100644 --- a/packages/synapse-interface/pages/state-managed-bridge/index.tsx +++ b/packages/synapse-interface/pages/state-managed-bridge/index.tsx @@ -207,6 +207,7 @@ const StateManagedBridge = () => { address, destinationAddress, pausedModulesKey, + synapseSDK, ]) const getAndSetBridgeQuote = async () => { diff --git a/packages/synapse-interface/slices/bridgeQuote/thunks.ts b/packages/synapse-interface/slices/bridgeQuote/thunks.ts index 45e7283201..1b03edce68 100644 --- a/packages/synapse-interface/slices/bridgeQuote/thunks.ts +++ b/packages/synapse-interface/slices/bridgeQuote/thunks.ts @@ -11,6 +11,7 @@ import { calculateExchangeRate } from '@/utils/calculateExchangeRate' import { getPausedBridgeModuleNamesForRoute } from '@/utils/getPausedBridgeModuleNamesForRoute' import { Token } from '@/utils/types' import { BridgeModulePause } from '@/components/Maintenance/Maintenance' +import { bridgeQuoteSingleFlight } from '@/utils/bridgeQuoteSingleFlight' export const fetchBridgeQuote = createAsyncThunk( 'bridgeQuote/fetchBridgeQuote', @@ -47,7 +48,7 @@ export const fetchBridgeQuote = createAsyncThunk( ? destinationAddress : address - const allQuotes = await synapseSDK.bridgeV2({ + const allQuotes = await bridgeQuoteSingleFlight(synapseSDK, { fromChainId, toChainId, fromToken: fromToken.addresses[fromChainId], diff --git a/packages/synapse-interface/utils/bridgeQuoteSingleFlight.ts b/packages/synapse-interface/utils/bridgeQuoteSingleFlight.ts new file mode 100644 index 0000000000..0337e61bd3 --- /dev/null +++ b/packages/synapse-interface/utils/bridgeQuoteSingleFlight.ts @@ -0,0 +1,29 @@ +// Coalesce only simultaneous, identical SDK requests. Resolved quotes (and +// their fees) are never reused for a later refresh. +const requests = new WeakMap>>() + +export const bridgeQuoteSingleFlight = >( + sdk: { bridgeV2: (params: T) => Promise }, + params: T +): Promise => { + const key = JSON.stringify( + Object.keys(params) + .sort((left, right) => left.localeCompare(right)) + .map((field) => [field, typeof params[field], params[field]]) + ) + let sdkRequests = requests.get(sdk) + if (!sdkRequests) { + sdkRequests = new Map() + requests.set(sdk, sdkRequests) + } + const existing = sdkRequests.get(key) + if (existing !== undefined) return existing + + const request = Promise.resolve().then(() => sdk.bridgeV2(params)) + sdkRequests.set(key, request) + const clear = () => { + if (sdkRequests.get(key) === request) sdkRequests.delete(key) + } + request.then(clear, clear) + return request +} diff --git a/packages/synapse-interface/utils/hooks/useGasEstimator.ts b/packages/synapse-interface/utils/hooks/useGasEstimator.ts index c76d35e49f..1d9467038c 100644 --- a/packages/synapse-interface/utils/hooks/useGasEstimator.ts +++ b/packages/synapse-interface/utils/hooks/useGasEstimator.ts @@ -14,6 +14,7 @@ import { calculateGasCost } from '../calculateGasCost' import { stringToBigInt, formatBigIntToString } from '../bigint/format' import { Token } from '../types' import { wagmiConfig } from '@/wagmiConfig' +import { bridgeQuoteSingleFlight } from '@/utils/bridgeQuoteSingleFlight' import { fetchGasData, setGasLimit, @@ -154,7 +155,7 @@ const getBridgePayload = async ( userAddress: string ) => { try { - const quotes = await synapseSDK.bridgeV2({ + const quotes = await bridgeQuoteSingleFlight(synapseSDK, { fromChainId, toChainId, fromToken: fromToken.addresses[fromChainId], diff --git a/packages/synapse-interface/utils/providers/SynapseProvider.tsx b/packages/synapse-interface/utils/providers/SynapseProvider.tsx index e593f4cd52..98c37ee43e 100644 --- a/packages/synapse-interface/utils/providers/SynapseProvider.tsx +++ b/packages/synapse-interface/utils/providers/SynapseProvider.tsx @@ -12,38 +12,67 @@ export const SynapseContext = createContext(null) export const SynapseProvider = memo( ({ children, chains }: { children: React.ReactNode; chains: any[] }) => { - const synapseProviders = chains.map((chain) => { - const providerUrls = [ - `/api/rpc/${chain.id}`, - chain?.configRpc, - chain?.fallbackRpc, - ] - - // Set priority based on list order - const providerConfigs: FallbackProviderConfig[] = providerUrls.map( - (url, index) => ({ - provider: new TransportAwareJsonRpcProvider(url, chain.id), - priority: index, - stallTimeout: 750, - }) - ) - - // Use quorum of 1 - return new FallbackProvider(providerConfigs, 1) - }) - - const providerMap = chains.reduce((map, chain) => { - map[chain.id] = synapseProviders.find( - (provider) => provider.network.chainId === chain.id - ) - return map - }, {}) - - const chainIds = chains.map((chain) => chain.id) - const synapseSDK = new SynapseSDK(chainIds, synapseProviders) + const configurationKey = JSON.stringify( + chains.map(({ id, configRpc, fallbackRpc }) => ({ + id, + configRpc, + fallbackRpc, + })) + ) + const configuration = useMemo( + () => JSON.parse(configurationKey), + [configurationKey] + ) + const synapseProviders = useMemo( + () => + configuration.map((chain) => { + const providerUrls = [ + `/api/rpc/${chain.id}`, + chain?.configRpc, + chain?.fallbackRpc, + ] + + // Set priority based on list order + const providerConfigs: FallbackProviderConfig[] = providerUrls.map( + (url, index) => ({ + provider: new TransportAwareJsonRpcProvider(url, chain.id), + priority: index, + stallTimeout: 750, + }) + ) + + // Use quorum of 1 + return new FallbackProvider(providerConfigs, 1) + }), + [configuration] + ) + + const providerMap = useMemo( + () => + configuration.reduce((map, chain) => { + map[chain.id] = synapseProviders.find( + (provider) => provider.network.chainId === chain.id + ) + return map + }, {}), + [configuration, synapseProviders] + ) + + const synapseSDK = useMemo( + () => + new SynapseSDK( + configuration.map((chain) => chain.id), + synapseProviders + ), + [configuration, synapseProviders] + ) + const context = useMemo( + () => ({ synapseSDK, providerMap }), + [synapseSDK, providerMap] + ) return ( - + {children} ) diff --git a/packages/widget/src/providers/SynapseProvider.test.tsx b/packages/widget/src/providers/SynapseProvider.test.tsx new file mode 100644 index 0000000000..fe9d0bf690 --- /dev/null +++ b/packages/widget/src/providers/SynapseProvider.test.tsx @@ -0,0 +1,60 @@ +import { renderHook } from '@testing-library/react' +import { SynapseSDK } from '@synapsecns/sdk-router' +import { FallbackProvider } from '@ethersproject/providers' + +import { SynapseProvider, useSynapseContext } from './SynapseProvider' + +jest.mock('@synapsecns/sdk-router', () => ({ SynapseSDK: jest.fn() })) +jest.mock('@ethersproject/providers', () => ({ + StaticJsonRpcProvider: jest + .fn() + .mockImplementation((url, id) => ({ url, network: { chainId: id } })), + FallbackProvider: jest.fn().mockImplementation((configs) => ({ + configs, + network: configs[0].provider.network, + })), +})) + +const initial = [ + { + id: 1, + rpcUrls: { primary: 'https://primary', fallback: 'https://fallback' }, + }, +] as any + +describe('widget SDK provider lifetime', () => { + beforeEach(() => { + jest.clearAllMocks() + ;(SynapseSDK as jest.Mock).mockImplementation(() => ({})) + }) + + it('retains equivalent providers and SDK, but rebuilds when custom RPC changes', () => { + let chains = initial + let customRpcs = { 1: 'https://custom' } + const wrapper = ({ children }) => ( + + {children} + + ) + const { result, rerender } = renderHook(useSynapseContext, { wrapper }) + const context = result.current + chains = initial.map((chain) => ({ + ...chain, + rpcUrls: { ...chain.rpcUrls }, + })) + customRpcs = { ...customRpcs } + rerender() + expect(result.current).toBe(context) + expect(SynapseSDK).toHaveBeenCalledTimes(1) + expect(FallbackProvider).toHaveBeenCalledTimes(1) + customRpcs = { 1: 'https://replacement' } + rerender() + expect(result.current).not.toBe(context) + expect(SynapseSDK).toHaveBeenCalledTimes(2) + expect(FallbackProvider).toHaveBeenCalledTimes(2) + expect( + (FallbackProvider as unknown as jest.Mock).mock.calls[1][0][0].provider + .url + ).toBe('https://replacement') + }) +}) diff --git a/packages/widget/src/providers/SynapseProvider.tsx b/packages/widget/src/providers/SynapseProvider.tsx index be059b15fa..86869e839d 100644 --- a/packages/widget/src/providers/SynapseProvider.tsx +++ b/packages/widget/src/providers/SynapseProvider.tsx @@ -19,21 +19,25 @@ export const SynapseProvider = memo( chains: Chain[] customRpcs?: CustomRpcs }) => { + const configurationKey = JSON.stringify( + chains.map((chain) => ({ + id: chain.id, + urls: customRpcs?.[chain.id] + ? [ + customRpcs[chain.id], + chain.rpcUrls.primary, + chain.rpcUrls.fallback, + ] + : [chain.rpcUrls.primary, chain.rpcUrls.fallback], + })) + ) + const configuration = useMemo( + () => JSON.parse(configurationKey), + [configurationKey] + ) const synapseProviders = useMemo(() => { - return chains.map((chain) => { - let providerUrls - - if (customRpcs && customRpcs[chain.id]) { - providerUrls = [ - customRpcs[chain.id], - chain?.rpcUrls.primary, - chain?.rpcUrls.fallback, - ] - } else { - providerUrls = [chain?.rpcUrls.primary, chain?.rpcUrls.fallback] - } - - const providerConfigs: FallbackProviderConfig[] = providerUrls.map( + return configuration.map((chain) => { + const providerConfigs: FallbackProviderConfig[] = chain.urls.map( (url, index) => ({ provider: new StaticJsonRpcProvider(url, chain.id), priority: index, @@ -43,26 +47,31 @@ export const SynapseProvider = memo( return new FallbackProvider(providerConfigs, 1) }) - }, [chains]) + }, [configuration]) const providerMap = useMemo(() => { - return chains.reduce((map, chain) => { + return configuration.reduce((map, chain) => { map[chain.id] = synapseProviders.find( (provider) => provider.network.chainId === chain.id ) return map }, {}) - }, [chains, synapseProviders]) + }, [configuration, synapseProviders]) - const chainIds = chains.map((chain) => chain.id) const synapseSDK = useMemo( - () => new SynapseSDK(chainIds, synapseProviders), - [chainIds, synapseProviders] + () => + new SynapseSDK( + configuration.map((chain) => chain.id), + synapseProviders + ), + [configuration, synapseProviders] + ) + const context = useMemo( + () => ({ synapseSDK, providerMap, synapseProviders }), + [synapseSDK, providerMap, synapseProviders] ) return ( - + {children} )