Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
})
})
20 changes: 19 additions & 1 deletion packages/synapse-interface/__tests__/synBridgeQuote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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],
Expand Down
51 changes: 51 additions & 0 deletions packages/synapse-interface/__tests__/synapseProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<SynapseProvider chains={chains}>{children}</SynapseProvider>
)
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)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ const StateManagedBridge = () => {
address,
destinationAddress,
pausedModulesKey,
synapseSDK,
])

const getAndSetBridgeQuote = async () => {
Expand Down
3 changes: 2 additions & 1 deletion packages/synapse-interface/slices/bridgeQuote/thunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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],
Expand Down
29 changes: 29 additions & 0 deletions packages/synapse-interface/utils/bridgeQuoteSingleFlight.ts
Original file line number Diff line number Diff line change
@@ -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<object, Map<string, Promise<any>>>()

export const bridgeQuoteSingleFlight = <T extends Record<string, unknown>>(
sdk: { bridgeV2: (params: T) => Promise<any> },
params: T
): Promise<any> => {
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
}
3 changes: 2 additions & 1 deletion packages/synapse-interface/utils/hooks/useGasEstimator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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],
Expand Down
89 changes: 59 additions & 30 deletions packages/synapse-interface/utils/providers/SynapseProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<SynapseContext.Provider value={{ synapseSDK, providerMap }}>
<SynapseContext.Provider value={context}>
{children}
</SynapseContext.Provider>
)
Expand Down
Loading
Loading