From d7cf2f55e12a2e4a7f7aacb73c7c611ca6c75cb0 Mon Sep 17 00:00:00 2001 From: Gancho Radkov <43912948+ganchoradkov@users.noreply.github.com> Date: Wed, 19 Feb 2025 15:40:01 +0200 Subject: [PATCH] fix: semaphore balance calls (#3879) --- packages/adapters/bitcoin/src/adapter.ts | 51 ++++++++++++------- .../bitcoin/tests/BitcoinAdapter.test.ts | 44 ++++++++++++++++ packages/adapters/ethers/src/client.ts | 44 +++++++++++----- .../adapters/ethers/src/tests/client.test.ts | 42 +++++++++++++++ packages/adapters/ethers5/src/client.ts | 46 +++++++++++------ .../adapters/ethers5/src/tests/client.test.ts | 42 +++++++++++++++ packages/adapters/solana/src/client.ts | 43 ++++++++++------ .../adapters/solana/src/tests/client.test.ts | 37 ++++++++++++++ packages/adapters/wagmi/src/client.ts | 39 +++++++++----- .../adapters/wagmi/src/tests/client.test.ts | 39 ++++++++++++++ 10 files changed, 352 insertions(+), 75 deletions(-) diff --git a/packages/adapters/bitcoin/src/adapter.ts b/packages/adapters/bitcoin/src/adapter.ts index 77060a52c0..0560cb8d9f 100644 --- a/packages/adapters/bitcoin/src/adapter.ts +++ b/packages/adapters/bitcoin/src/adapter.ts @@ -18,6 +18,7 @@ import { UnitsUtil } from './utils/UnitsUtil.js' export class BitcoinAdapter extends AdapterBlueprint { private eventsToUnbind: (() => void)[] = [] private api: BitcoinApi.Interface + private balancePromises: Record> = {} constructor({ api = {}, ...params }: BitcoinAdapter.ConstructorParams = {}) { super({ @@ -172,31 +173,45 @@ export class BitcoinAdapter extends AdapterBlueprint { const network = params.caipNetwork if (network?.chainNamespace === 'bip122') { - const utxos = await this.api.getUTXOs({ - network, - address: params.address - }) - const caipAddress = `${params?.caipNetwork?.caipNetworkId}:${params.address}` + + const cachedPromise = this.balancePromises[caipAddress] + if (cachedPromise) { + return cachedPromise + } + const cachedBalance = StorageUtil.getNativeBalanceCacheForCaipAddress(caipAddress) if (cachedBalance) { return { balance: cachedBalance.balance, symbol: cachedBalance.symbol } } - - const balance = utxos.reduce((acc, utxo) => acc + utxo.value, 0) - const formattedBalance = UnitsUtil.parseSatoshis(balance.toString(), network) - - StorageUtil.updateNativeBalanceCache({ - caipAddress, - balance: formattedBalance, - symbol: network.nativeCurrency.symbol, - timestamp: Date.now() + this.balancePromises[caipAddress] = new Promise( + async resolve => { + const utxos = await this.api.getUTXOs({ + network, + address: params.address + }) + + const balance = utxos.reduce((acc, utxo) => acc + utxo.value, 0) + const formattedBalance = UnitsUtil.parseSatoshis(balance.toString(), network) + + StorageUtil.updateNativeBalanceCache({ + caipAddress, + balance: formattedBalance, + symbol: network.nativeCurrency.symbol, + timestamp: Date.now() + }) + + resolve({ + balance: formattedBalance, + symbol: network.nativeCurrency.symbol + }) + } + ).finally(() => { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete this.balancePromises[caipAddress] }) - return { - balance: formattedBalance, - symbol: network.nativeCurrency.symbol - } + return this.balancePromises[caipAddress] || Promise.resolve({ balance: '0', symbol: '' }) } // Get balance diff --git a/packages/adapters/bitcoin/tests/BitcoinAdapter.test.ts b/packages/adapters/bitcoin/tests/BitcoinAdapter.test.ts index d7c735734c..2bbb09fc83 100644 --- a/packages/adapters/bitcoin/tests/BitcoinAdapter.test.ts +++ b/packages/adapters/bitcoin/tests/BitcoinAdapter.test.ts @@ -304,6 +304,50 @@ describe('BitcoinAdapter', () => { StorageUtil.clearAddressCache() }) + it('should call getBalance once even when multiple adapter requests are sent at the same time', async () => { + // delay the response to simulate http request latency + const latency = 1000 + const numSimultaneousRequests = 10 + const expectedSentRequests = 1 + + api.getUTXOs.mockResolvedValue( + new Promise(resolve => { + setTimeout(() => { + resolve([ + mockUTXO({ value: 10000 }), + mockUTXO({ value: 20000 }), + mockUTXO({ value: 30000 }), + mockUTXO({ value: 10000000000 }) + ]) + }, latency) + }) as any + ) + + const result = await Promise.all([ + ...Array.from({ length: numSimultaneousRequests }).map(() => + adapter.getBalance({ + address: 'mock_address', + chainId: bitcoin.id, + caipNetwork: bitcoin + }) + ) + ]) + + expect(api.getUTXOs).toHaveBeenCalledTimes(expectedSentRequests) + expect(result.length).toBe(numSimultaneousRequests) + expect(expectedSentRequests).to.be.lt(numSimultaneousRequests) + + // verify all calls got the same balance + for (const balance of result) { + expect(balance).toEqual({ + balance: '100.0006', + symbol: 'BTC' + }) + } + + StorageUtil.clearAddressCache() + }) + it('should return empty balance if no UTXOs', async () => { api.getUTXOs.mockResolvedValueOnce([]) diff --git a/packages/adapters/ethers/src/client.ts b/packages/adapters/ethers/src/client.ts index 439a895c9e..6e55459359 100644 --- a/packages/adapters/ethers/src/client.ts +++ b/packages/adapters/ethers/src/client.ts @@ -30,6 +30,7 @@ export interface EIP6963ProviderDetail { export class EthersAdapter extends AdapterBlueprint { private ethersConfig?: ProviderType public adapterType = 'ethers' + private balancePromises: Record> = {} constructor() { super({}) @@ -463,30 +464,45 @@ export class EthersAdapter extends AdapterBlueprint { const caipNetwork = this.caipNetworks?.find((c: CaipNetwork) => c.id === params.chainId) if (caipNetwork && caipNetwork.chainNamespace === 'eip155') { - const jsonRpcProvider = new JsonRpcProvider(caipNetwork.rpcUrls.default.http[0], { - chainId: caipNetwork.id as number, - name: caipNetwork.name - }) - const caipAddress = `${caipNetwork.caipNetworkId}:${params.address}` + + const cachedPromise = this.balancePromises[caipAddress] + if (cachedPromise) { + return cachedPromise + } const cachedBalance = StorageUtil.getNativeBalanceCacheForCaipAddress(caipAddress) if (cachedBalance) { return { balance: cachedBalance.balance, symbol: cachedBalance.symbol } } + const jsonRpcProvider = new JsonRpcProvider(caipNetwork.rpcUrls.default.http[0], { + chainId: caipNetwork.id as number, + name: caipNetwork.name + }) + if (jsonRpcProvider) { try { - const balance = await jsonRpcProvider.getBalance(params.address) - const formattedBalance = formatEther(balance) - - StorageUtil.updateNativeBalanceCache({ - caipAddress, - balance: formattedBalance, - symbol: caipNetwork.nativeCurrency.symbol, - timestamp: Date.now() + this.balancePromises[caipAddress] = new Promise( + async resolve => { + const balance = await jsonRpcProvider.getBalance(params.address) + + const formattedBalance = formatEther(balance) + + StorageUtil.updateNativeBalanceCache({ + caipAddress, + balance: formattedBalance, + symbol: caipNetwork.nativeCurrency.symbol, + timestamp: Date.now() + }) + + resolve({ balance: formattedBalance, symbol: caipNetwork.nativeCurrency.symbol }) + } + ).finally(() => { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete this.balancePromises[caipAddress] }) - return { balance: formattedBalance, symbol: caipNetwork.nativeCurrency.symbol } + return this.balancePromises[caipAddress] || { balance: '', symbol: '' } } catch (error) { return { balance: '', symbol: '' } } diff --git a/packages/adapters/ethers/src/tests/client.test.ts b/packages/adapters/ethers/src/tests/client.test.ts index ed21f7e101..d36d154840 100644 --- a/packages/adapters/ethers/src/tests/client.test.ts +++ b/packages/adapters/ethers/src/tests/client.test.ts @@ -312,6 +312,48 @@ describe('EthersAdapter', () => { symbol: 'ETH' }) }) + + it('should call getBalance once even when multiple adapter requests are sent at the same time', async () => { + adapter.caipNetworks = mockCaipNetworks + const mockBalance = BigInt(1500000000000000000) + // delay the response to simulate http request latency + const latency = 1000 + const numSimultaneousRequests = 10 + const expectedSentRequests = 1 + let mockedImplementationCalls = 0 + vi.mocked(JsonRpcProvider).mockImplementation( + () => + ({ + getBalance: vi.fn().mockResolvedValue( + new Promise(resolve => { + mockedImplementationCalls++ + setTimeout(() => resolve(mockBalance), latency) + }) + ) + }) as any + ) + + const result = await Promise.all([ + ...Array.from({ length: numSimultaneousRequests }).map(() => + adapter.getBalance({ + address: '0x123', + chainId: 1 + }) + ) + ]) + + expect(mockedImplementationCalls).to.eql(expectedSentRequests) + expect(result.length).toBe(numSimultaneousRequests) + expect(expectedSentRequests).to.be.lt(numSimultaneousRequests) + + // verify all calls got the same balance + for (const balance of result) { + expect(balance).toEqual({ + balance: '1.5', + symbol: 'ETH' + }) + } + }) }) describe('EthersAdapter -getProfile', () => { diff --git a/packages/adapters/ethers5/src/client.ts b/packages/adapters/ethers5/src/client.ts index 73d4ce0591..59cdf70993 100644 --- a/packages/adapters/ethers5/src/client.ts +++ b/packages/adapters/ethers5/src/client.ts @@ -31,6 +31,7 @@ export interface EIP6963ProviderDetail { export class Ethers5Adapter extends AdapterBlueprint { private ethersConfig?: ProviderType public adapterType = 'ethers' + private balancePromises: Record> = {} constructor() { super({}) @@ -462,6 +463,18 @@ export class Ethers5Adapter extends AdapterBlueprint { const caipNetwork = this.caipNetworks?.find((c: CaipNetwork) => c.id === params.chainId) if (caipNetwork) { + const caipAddress = `${caipNetwork.caipNetworkId}:${params.address}` + + const cachedPromise = this.balancePromises[caipAddress] + if (cachedPromise) { + return cachedPromise + } + + const cachedBalance = StorageUtil.getNativeBalanceCacheForCaipAddress(caipAddress) + if (cachedBalance) { + return { balance: cachedBalance.balance, symbol: cachedBalance.symbol } + } + const jsonRpcProvider = new ethers.providers.JsonRpcProvider( caipNetwork.rpcUrls.default.http[0], { @@ -470,25 +483,28 @@ export class Ethers5Adapter extends AdapterBlueprint { } ) - const caipAddress = `${caipNetwork.caipNetworkId}:${params.address}` - const cachedBalance = StorageUtil.getNativeBalanceCacheForCaipAddress(caipAddress) - if (cachedBalance) { - return { balance: cachedBalance.balance, symbol: cachedBalance.symbol } - } - if (jsonRpcProvider) { try { - const balance = await jsonRpcProvider.getBalance(params.address) - const formattedBalance = formatEther(balance) - - StorageUtil.updateNativeBalanceCache({ - caipAddress, - balance: formattedBalance, - symbol: caipNetwork.nativeCurrency.symbol, - timestamp: Date.now() + this.balancePromises[caipAddress] = new Promise( + async resolve => { + const balance = await jsonRpcProvider.getBalance(params.address) + const formattedBalance = formatEther(balance) + + StorageUtil.updateNativeBalanceCache({ + caipAddress, + balance: formattedBalance, + symbol: caipNetwork.nativeCurrency.symbol, + timestamp: Date.now() + }) + + resolve({ balance: formattedBalance, symbol: caipNetwork.nativeCurrency.symbol }) + } + ).finally(() => { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete this.balancePromises[caipAddress] }) - return { balance: formattedBalance, symbol: caipNetwork.nativeCurrency.symbol } + return this.balancePromises[caipAddress] || { balance: '', symbol: '' } } catch (error) { return { balance: '', symbol: '' } } diff --git a/packages/adapters/ethers5/src/tests/client.test.ts b/packages/adapters/ethers5/src/tests/client.test.ts index 70ff5a0d11..0b03dd996a 100644 --- a/packages/adapters/ethers5/src/tests/client.test.ts +++ b/packages/adapters/ethers5/src/tests/client.test.ts @@ -306,6 +306,48 @@ describe('Ethers5Adapter', () => { }) }) + it('should call getBalance once even when multiple adapter requests are sent at the same time', async () => { + adapter.caipNetworks = mockCaipNetworks + const mockBalance = BigInt(1500000000000000000) + // delay the response to simulate http request latency + const latency = 1000 + const numSimultaneousRequests = 10 + const expectedSentRequests = 1 + let mockedImplementationCalls = 0 + vi.mocked(providers.JsonRpcProvider).mockImplementation( + () => + ({ + getBalance: vi.fn().mockResolvedValue( + new Promise(resolve => { + mockedImplementationCalls++ + setTimeout(() => resolve(mockBalance), latency) + }) + ) + }) as any + ) + + const result = await Promise.all([ + ...Array.from({ length: numSimultaneousRequests }).map(() => + adapter.getBalance({ + address: '0x123', + chainId: 1 + }) + ) + ]) + + expect(mockedImplementationCalls).to.eql(expectedSentRequests) + expect(result.length).toBe(numSimultaneousRequests) + expect(expectedSentRequests).to.be.lt(numSimultaneousRequests) + + // verify all calls got the same balance + for (const balance of result) { + expect(balance).toEqual({ + balance: '1.5', + symbol: 'ETH' + }) + } + }) + describe('Ethers5Adapter -getProfile', () => { it('should get profile successfully', async () => { const mockEnsName = 'test.eth' diff --git a/packages/adapters/solana/src/client.ts b/packages/adapters/solana/src/client.ts index 29d3c28bf5..294bc40b76 100644 --- a/packages/adapters/solana/src/client.ts +++ b/packages/adapters/solana/src/client.ts @@ -38,6 +38,7 @@ export class SolanaAdapter extends AdapterBlueprint { private connectionSettings: Commitment | ConnectionConfig public adapterType = 'solana' public wallets?: BaseWalletAdapter[] + private balancePromises: Record> = {} constructor(options: AdapterOptions = {}) { super({}) @@ -244,29 +245,41 @@ export class SolanaAdapter extends AdapterBlueprint { ) const caipAddress = `${params?.caipNetwork?.caipNetworkId}:${params.address}` + const cachedPromise = this.balancePromises[caipAddress] + if (cachedPromise) { + return cachedPromise + } const cachedBalance = StorageUtil.getNativeBalanceCacheForCaipAddress(caipAddress) if (cachedBalance) { return { balance: cachedBalance.balance, symbol: cachedBalance.symbol } } + this.balancePromises[caipAddress] = new Promise( + async resolve => { + const balance = await connection.getBalance(new PublicKey(params.address)) + const formattedBalance = (balance / SolConstantsUtil.LAMPORTS_PER_SOL).toString() + + StorageUtil.updateNativeBalanceCache({ + caipAddress, + balance: formattedBalance, + symbol: params.caipNetwork?.nativeCurrency.symbol || 'SOL', + timestamp: Date.now() + }) - const balance = await connection.getBalance(new PublicKey(params.address)) - const formattedBalance = (balance / SolConstantsUtil.LAMPORTS_PER_SOL).toString() + if (!params.caipNetwork) { + throw new Error('caipNetwork is required') + } - StorageUtil.updateNativeBalanceCache({ - caipAddress, - balance: formattedBalance, - symbol: params.caipNetwork?.nativeCurrency.symbol || 'SOL', - timestamp: Date.now() + resolve({ + balance: formattedBalance, + symbol: params.caipNetwork?.nativeCurrency.symbol + }) + } + ).finally(() => { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete this.balancePromises[caipAddress] }) - if (!params.caipNetwork) { - throw new Error('caipNetwork is required') - } - - return { - balance: formattedBalance, - symbol: params.caipNetwork?.nativeCurrency.symbol - } + return this.balancePromises[caipAddress] || { balance: '0', symbol: 'SOL' } } public override async switchNetwork(params: AdapterBlueprint.SwitchNetworkParams): Promise { diff --git a/packages/adapters/solana/src/tests/client.test.ts b/packages/adapters/solana/src/tests/client.test.ts index 87a954bbc5..1ccbe576e7 100644 --- a/packages/adapters/solana/src/tests/client.test.ts +++ b/packages/adapters/solana/src/tests/client.test.ts @@ -193,6 +193,43 @@ describe('SolanaAdapter', () => { symbol: 'SOL' }) }) + it('should get balance successfully mult', async () => { + const numSimultaneousRequests = 10 + const expectedSentRequests = 1 + vi.mock('@solana/web3.js', () => ({ + Connection: vi.fn(endpoint => ({ + getBalance: vi.fn().mockResolvedValue( + new Promise(resolve => { + setTimeout(() => resolve(1500000000), 1000) + }) + ), + getSignatureStatus: vi.fn().mockResolvedValue({ value: true }), + rpcEndpoint: endpoint + })), + PublicKey: vi.fn(key => ({ toBase58: () => key })) + })) + + const result = await Promise.all([ + ...Array.from({ length: numSimultaneousRequests }).map(() => + adapter.getBalance({ + address: 'mock-address', + chainId: '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + caipNetwork: mockCaipNetworks[0] + }) + ) + ]) + + expect(result.length).toBe(numSimultaneousRequests) + expect(expectedSentRequests).to.be.lt(numSimultaneousRequests) + + // verify all calls got the same balance + for (const balance of result) { + expect(balance).toEqual({ + balance: '1.5', + symbol: 'SOL' + }) + } + }) }) describe('SolanaAdapter - signMessage', () => { diff --git a/packages/adapters/wagmi/src/client.ts b/packages/adapters/wagmi/src/client.ts index 3b50d92624..305890b92a 100644 --- a/packages/adapters/wagmi/src/client.ts +++ b/packages/adapters/wagmi/src/client.ts @@ -78,6 +78,7 @@ export class WagmiAdapter extends AdapterBlueprint { private pendingTransactionsFilter: PendingTransactionsFilter private unwatchPendingTransactions: (() => void) | undefined + private balancePromises: Record> = {} constructor( configParams: Partial & { @@ -571,27 +572,39 @@ export class WagmiAdapter extends AdapterBlueprint { if (caipNetwork && this.wagmiConfig) { const caipAddress = `${caipNetwork.caipNetworkId}:${params.address}` + const cachedPromise = this.balancePromises[caipAddress] + if (cachedPromise) { + return cachedPromise + } const cachedBalance = StorageUtil.getNativeBalanceCacheForCaipAddress(caipAddress) if (cachedBalance) { return { balance: cachedBalance.balance, symbol: cachedBalance.symbol } } - const chainId = Number(params.chainId) - const balance = await getBalance(this.wagmiConfig, { - address: params.address as Hex, - chainId, - token: params.tokens?.[caipNetwork.caipNetworkId]?.address as Hex - }) - - StorageUtil.updateNativeBalanceCache({ - caipAddress, - balance: balance.formatted, - symbol: balance.symbol, - timestamp: Date.now() + this.balancePromises[caipAddress] = new Promise( + async resolve => { + const chainId = Number(params.chainId) + const balance = await getBalance(this.wagmiConfig, { + address: params.address as Hex, + chainId, + token: params.tokens?.[caipNetwork.caipNetworkId]?.address as Hex + }) + + StorageUtil.updateNativeBalanceCache({ + caipAddress, + balance: balance.formatted, + symbol: balance.symbol, + timestamp: Date.now() + }) + resolve({ balance: balance.formatted, symbol: balance.symbol }) + } + ).finally(() => { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete this.balancePromises[caipAddress] }) - return { balance: balance.formatted, symbol: balance.symbol } + return this.balancePromises[caipAddress] || { balance: '', symbol: '' } } return { balance: '', symbol: '' } diff --git a/packages/adapters/wagmi/src/tests/client.test.ts b/packages/adapters/wagmi/src/tests/client.test.ts index e6fb2ea973..beff023bfe 100644 --- a/packages/adapters/wagmi/src/tests/client.test.ts +++ b/packages/adapters/wagmi/src/tests/client.test.ts @@ -358,6 +358,45 @@ describe('WagmiAdapter', () => { }) }) + it('should call getBalance once even when multiple adapter requests are sent at the same time', async () => { + // delay the response to simulate http request latency + const latency = 1000 + const numSimultaneousRequests = 10 + const expectedSentRequests = 1 + + vi.mocked(getBalance).mockResolvedValue( + new Promise(resolve => { + setTimeout(() => { + resolve({ + formatted: '1.5', + symbol: 'ETH' + }) + }, latency) + }) as any + ) + + const result = await Promise.all([ + ...Array.from({ length: numSimultaneousRequests }).map(() => + adapter.getBalance({ + address: '0x123', + chainId: 1 + }) + ) + ]) + + expect(getBalance).toHaveBeenCalledTimes(expectedSentRequests) + expect(result.length).toBe(numSimultaneousRequests) + expect(expectedSentRequests).to.be.lt(numSimultaneousRequests) + + // verify all calls got the same balance + for (const balance of result) { + expect(balance).toEqual({ + balance: '1.5', + symbol: 'ETH' + }) + } + }) + it('should return empty balance when network not found', async () => { const result = await adapter.getBalance({ address: '0x123',